Skip to content

feat: type-safe IQ protocol architecture with derive macros - #237

Merged
jlucaso1 merged 18 commits into
mainfrom
refactor-stanza-architecture
Jan 23, 2026
Merged

feat: type-safe IQ protocol architecture with derive macros#237
jlucaso1 merged 18 commits into
mainfrom
refactor-stanza-architecture

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Added a type-safe protocol layer, derive macros for protocol nodes/enums, and many IQ-driven features (groups, blocklist, contacts, media connection, MEX GraphQL, pre-keys, spam reporting, usync, props, privacy, keepalive, dirty/passive).
    • New/extended public handles: chatstate, mex (query/mutate), blocking, presence, media connection with expiry, prekey flows, richer group APIs.
  • Refactor

    • Centralized spec-driven execute flow replaces ad-hoc IQ/node construction and parsing; added node parsing helpers.
  • Documentation

    • Expanded architecture, debugging, and verification guidance.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Jan 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduce a type-safe IQ/spec framework and proc-macro derives in wacore; add many typed IQ modules (groups, usync, prekeys, mex, mediaconn, blocklist, etc.), wire a Client::execute(IqSpec) orchestration, add node parsing helpers and validated newtypes, and refactor runtime feature modules to call spec-driven IQ executions and safer parsers.

Changes

Cohort / File(s) Summary
Workspace & Derives
Cargo.toml, wacore/Cargo.toml, wacore/derive/Cargo.toml, wacore/derive/src/lib.rs
Add wacore-derive proc-macro crate to workspace and implement derives ProtocolNode, EmptyNode, StringEnum.
Core protocol & IQ infra
wacore/src/lib.rs, wacore/src/protocol.rs, wacore/src/iq/spec.rs, wacore/src/iq/node.rs, wacore/src/iq/mod.rs
Add ProtocolNode trait, export derive macros, macros for validated newtypes, introduce IqSpec trait and node parsing helpers; wire iq module.
Typed IQ modules (wacore)
wacore/src/iq/*
wacore/src/iq/{blocklist,contacts,groups,dirty,keepalive,mediaconn,mex,prekeys,spam_report,props,privacy,usync,passive}.rs
Large set of new IqSpec implementations plus domain types, builders, parsing/validation helpers and tests for blocklist, contacts, groups, keepalive, mediaconn, mex, prekeys, spam_report, props, privacy, usync, dirty, passive.
Client request API & orchestration
src/request.rs, src/client.rs
Add IqError::ParseError and Client::execute<S: IqSpec>(&self, spec) -> Result<S::Response, IqError>; refactor client internals to call execute(spec) and adjust post-login push-name/presence behavior.
Feature module refactors
src/features/*
src/features/{blocking,contacts,groups,mex,chatstate,presence,spam_report}.rs
Replace inline InfoQuery/Node logic with spec execution via client.execute(...), re-export/consume wacore::iq types, add feature handles/accessors (e.g., client.chatstate(), client.blocking()), update APIs/tests.
Runtime helpers & models
src/mediaconn.rs, src/prekeys.rs, src/features/mex.rs, src/keepalive.rs
Switch media, prekeys, mex, and keepalive flows to use corresponding Specs (MediaConnSpec, PreKey*, MexQuerySpec, KeepaliveSpec); add fields/methods like MediaConn::is_expired.
Enums / StringEnum adoption
wacore/src/{pair_code.rs,types/message.rs,types/spam_report.rs,request.rs}
Replace manual as_str/Display implementations with StringEnum derive and per-variant string attributes; adjust parsing, Display and defaults.
Binary attrs & error handling
wacore/binary/src/{attrs.rs,error.rs}
Add required_string API, deprecate .string() accessor, introduce BinaryError::MissingAttr, update tests.
Node parsing hardening
src/*, wacore/*, wacore/appstate/*
Harden many handlers/parsers to use optional/required accessors, log and skip malformed nodes (message parsing, receipts, handlers, usync, appstate), add safer attribute parsing and required-attr helpers.
Docs & examples
AGENTS.md, docs/captured-js/*, docs/src/features/*
Add Type‑Safe Protocol Node Architecture docs, derive guidance, node helpers, IQ file organization and examples; add docs directories and references.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as Client
    participant Spec as IqSpec
    participant Exec as Executor
    participant Server as Server

    Client->>Spec: construct spec (e.g., GroupQueryIq)
    Client->>Exec: execute(spec)
    Exec->>Spec: build_iq()
    Note over Spec,Exec: Spec returns InfoQuery/Node payload
    Exec->>Server: send(InfoQuery)
    Server-->>Exec: Node (response)
    Exec->>Spec: parse_response(Node)
    Spec-->>Exec: typed Response
    Exec-->>Client: Result<Response, IqError>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇
I hopped through tags and parsed each node,
I shaped enums, derived and stowed.
Specs whisper queries, responses reply—
Type-safe carrots keep the protocol spry.
A little rabbit cheers: "tests pass, oh my!"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: type-safe IQ protocol architecture with derive macros' accurately reflects the primary changes in this PR, which introduce type-safe IQ specifications, derive macros (ProtocolNode, StringEnum, EmptyNode), and comprehensive IQ module implementations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Jan 22, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchrefactor-stanza-architecture
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
binary_benchmark::unpack_group::bench_unpack_compressedInstructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
556.53 x 1e3
(+16.91%)Baseline: 476.05 x 1e3
499.85 x 1e3
(111.34%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
7,031.00
(-5.58%)Baseline: 7,446.27
7,818.58
(89.93%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
124,647.00
(-14.83%)Baseline: 146,358.64
153,676.58
(81.11%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
124,747.00
(-14.83%)Baseline: 146,459.73
153,782.72
(81.12%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,274.00
(0.00%)Baseline: 106,274.00
111,587.70
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,487.00
(0.00%)Baseline: 8,487.00
8,911.35
(95.24%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
49,338.00
(-0.47%)Baseline: 49,572.24
52,050.86
(94.79%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
3,025.00
(-6.92%)Baseline: 3,250.04
3,412.55
(88.64%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
🚨 view alert (🔔)
556,531.00
(+16.91%)Baseline: 476,048.76
499,851.19
(111.34%)

binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
779.00
(+0.02%)Baseline: 778.84
817.79
(95.26%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,681,907.00
(-0.25%)Baseline: 27,752,674.57
29,140,308.30
(95.00%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,568,973.00
(-0.02%)Baseline: 5,570,131.71
5,848,638.30
(95.22%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
176,934.00
(-0.51%)Baseline: 177,834.36
186,726.08
(94.76%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
177,750.00
(-0.50%)Baseline: 178,641.21
187,573.27
(94.76%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,149,184.00
(-0.65%)Baseline: 17,261,938.14
18,125,035.05
(94.62%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,577.00
(-0.02%)Baseline: 295,644.86
310,427.10
(95.22%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,553,056.00
(-0.34%)Baseline: 12,595,484.79
13,225,259.03
(94.92%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
715,209.00
(-0.13%)Baseline: 716,122.43
751,928.55
(95.12%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,819.00
(+0.08%)Baseline: 11,809.73
12,400.22
(95.31%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,837.00
(+0.21%)Baseline: 3,828.98
4,020.43
(95.44%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
88,033.00
(+0.04%)Baseline: 88,001.47
92,401.54
(95.27%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
80,074.00
(+0.04%)Baseline: 80,043.71
84,045.90
(95.27%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,035.00
(0.00%)Baseline: 51,035.00
53,586.75
(95.24%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,738.00
(+0.16%)Baseline: 5,728.73
6,015.17
(95.39%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,121.00
(+0.38%)Baseline: 2,112.98
2,218.63
(95.60%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,921.00
(+0.09%)Baseline: 21,901.67
22,996.75
(95.32%)
🐰 View full continuous benchmarking report in Bencher

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/prekeys.rs (3)

128-128: Critical bug: OsRng.unwrap_err() will always panic.

unwrap_err() expects an Err variant and panics on Ok. Since OsRng implements TryRngCore and typically succeeds, this will panic at runtime. You likely meant to use OsRng directly or handle it properly.

🐛 Proposed fix
-            let key_pair = KeyPair::generate(&mut OsRng.unwrap_err());
+            let key_pair = KeyPair::generate(&mut OsRng);

If KeyPair::generate requires CryptoRng + RngCore (which OsRng implements directly in rand 0.9), you can pass &mut OsRng directly. If you need the TryRngCore interface, use:

let key_pair = KeyPair::generate(&mut OsRng.unwrap_mut());

79-93: Dead code: found_count is never incremented.

The variable found_count is initialized to 0 but never modified within the loop, making the condition found_count >= WANTED_PRE_KEY_COUNT always false. This entire block appears to be incomplete or dead code.

♻️ Suggested fix: Remove dead code or complete the implementation

If this block is meant to find existing unuploaded keys, it needs proper implementation. Otherwise, remove it:

-        // Check if we have existing unuploaded keys by trying IDs sequentially
-        // We'll check a reasonable range to find existing keys
-        let found_count = 0;
-        for id in 1..=1000u32 {
-            if found_count >= WANTED_PRE_KEY_COUNT {
-                break;
-            }
-
-            if let Ok(Some(_record)) = backend.load_prekey(id).await {
-                // Check if this key was already uploaded by seeing if it exists on server
-                // For simplicity, assume unuploaded keys have a specific pattern or we track separately
-                // For now, we'll use existing keys if available but generate new ones with sequential IDs
-                break; // We'll generate new ones with better tracking
-            }
-        }

96-112: Performance concern: Sequential scan of up to 16M pre-key IDs.

This loop scans IDs from 1 to 16,777,215 sequentially, calling backend.load_prekey(id) for each. This is O(n) where n can be up to 16M, which could take an extremely long time and block the async runtime.

Per coding guidelines, heavy CPU-bound or blocking I/O tasks should be wrapped in tokio::task::spawn_blocking.

♻️ Suggested approach

Consider one of these alternatives:

  1. Track the highest pre-key ID in persistent storage instead of scanning:
// Store and retrieve highest_pre_key_id from backend
let highest_existing_id = backend.get_highest_prekey_id().await?.unwrap_or(0);
  1. If scanning is necessary, use binary search if the backend supports it, or at minimum limit the scan range and wrap in spawn_blocking:
let highest_existing_id = tokio::task::spawn_blocking(move || {
    // scanning logic here
}).await??;
  1. Use a gap-finding query if the backend supports it.
🤖 Fix all issues with AI agents
In `@wacore/Cargo.toml`:
- Line 36: wacore fails to build standalone because serde_json is pulled from
the workspace with default-features = false but wacore/src/iq/mex.rs calls
serde_json::to_vec and serde_json::from_slice which require the alloc feature;
update the serde_json entry in wacore/Cargo.toml to enable the alloc feature
(e.g. change serde_json = { workspace = true } to serde_json = { workspace =
true, features = ["alloc"], default-features = false }) so to_vec/from_slice
work when building the package alone.

In `@wacore/derive/src/lib.rs`:
- Around line 94-110: The generated try_from_node() body currently uses
node.attrs().string(`#attr_name`) for required attributes which returns an empty
string when missing; change those branches in the attr_fields -> field_parsers
map so required fields call
node.attrs().optional_string(`#attr_name`).ok_or_else(|| <appropriate error>)? (or
use .ok_or(...)?) and propagate the error with ? so missing required attributes
cause try_from_node() to return an error; update the generation logic that emits
`#field_ident` initializers to use optional_string + error propagation instead of
string() for fields where info.default is None.

In `@wacore/src/iq/mex.rs`:
- Around line 123-138: The serialization call using serde_json::to_vec(&payload)
in build_iq (where MexPayload wraps &serde_json::Value) is effectively
infallible here; instead of changing error handling semantics, document that
with a brief comment above the payload_bytes line explaining that MexPayload
contains only a &Value and serde_json::to_vec cannot fail in this usage (barring
custom serializers or non-string map keys), and keep the existing
unwrap_or_default() to preserve behavior; reference build_iq, MexPayload,
payload_bytes, and serde_json::to_vec in this comment.
- Around line 141-151: In parse_response, accept both NodeContent::Bytes and
NodeContent::String for the <result> node: locate the result_node extraction in
parse_response and match on result_node.content to handle
NodeContent::Bytes(bytes) by parsing via serde_json::from_slice(bytes) and
NodeContent::String(s) by parsing via serde_json::from_str(s), returning the
MexResponse in either case and returning the existing errors for missing or
unexpected content; reference the parse_response function, result_node,
NodeContent::Bytes, NodeContent::String, and the MexResponse type.

In `@wacore/src/types/message.rs`:
- Around line 6-21: The enum AddressingMode currently derives Serialize but will
serialize as "Pn"/"Lid" while as_str() and TryFrom expect "pn"/"lid"; add the
serde attribute #[serde(rename_all = "lowercase")] to the AddressingMode enum
declaration so serde will emit/accept lowercase values consistent with
AddressingMode::as_str() and any TryFrom implementations.
🧹 Nitpick comments (13)
wacore/derive/src/lib.rs (1)

58-71: Prefer syn::Error over panics for macro diagnostics

expect/panic! in derive entrypoints yields “proc-macro panicked” without spans. Emitting syn::Error produces clearer compiler output at the correct span.

♻️ Suggested pattern
- let tag = extract_tag(&input.attrs).expect("ProtocolNode requires #[protocol(tag = \"...\")]");
+ let tag = match extract_tag(&input.attrs) {
+     Some(tag) => tag,
+     None => {
+         return syn::Error::new_spanned(
+             &input.ident,
+             "ProtocolNode requires #[protocol(tag = \"...\")]",
+         )
+         .to_compile_error()
+         .into();
+     }
+ };
- let str_val = str_value.unwrap_or_else(|| {
-     panic!("StringEnum variant {} requires #[str = \"...\"] attribute", variant_ident)
- });
+ let str_val = match str_value {
+     Some(v) => v,
+     None => {
+         return syn::Error::new_spanned(
+             variant_ident,
+             format!("StringEnum variant {} requires #[str = \"...\"] attribute", variant_ident),
+         )
+         .to_compile_error()
+         .into();
+     }
+ };

Please sanity-check the diagnostics by compiling a minimal derive usage missing #[protocol(tag = "...")] or #[str = "..."] and confirm the error spans are clear.

Also applies to: 174-183, 331-336

wacore/src/iq/contacts.rs (2)

71-95: Consider taking Jid by value to avoid unnecessary clones when caller already owns the JID.

Per AGENTS.md, "IqSpec constructors should take &Jid instead of Jid to avoid forcing callers to clone." However, this creates a clone inside every constructor. If callers often already have an owned Jid, consider offering both new(jid: Jid, ...) and new_ref(jid: &Jid, ...) variants, or accept impl Into<Jid>.

This is a minor consistency point and the current approach is acceptable.


133-137: Empty id on success may indicate a malformed response.

When the picture node exists without an error, id defaults to an empty string if missing. This silently accepts potentially malformed responses. Consider logging a warning or returning an error when id is missing in a successful response.

Proposed fix
         let id = picture_node
             .attrs()
             .optional_string("id")
             .map(|s| s.to_string())
-            .unwrap_or_default();
+            .ok_or_else(|| anyhow!("Picture response missing 'id' attribute"))?;
wacore/src/iq/prekeys.rs (1)

84-96: Inconsistent error handling: missing <count> node returns error, but missing value attribute defaults silently.

Line 87 returns an error if <count> node is missing, but lines 89-93 silently default to 0 if the value attribute is missing or unparseable. Consider making this consistent—either both should error or both should default.

Option 1: Error on missing/invalid value (stricter)
         let count_str = count_node
             .attrs()
             .optional_string("value")
-            .unwrap_or("0");
-        let count = count_str.parse::<usize>().unwrap_or(0);
+            .ok_or_else(|| anyhow!("Missing 'value' attribute on <count> node"))?;
+        let count = count_str.parse::<usize>()
+            .map_err(|e| anyhow!("Invalid count value '{}': {}", count_str, e))?;
Option 2: Document the default behavior (lenient)
+        // Server may return <count/> without value attribute when count is 0
         let count_str = count_node
             .attrs()
             .optional_string("value")
             .unwrap_or("0");
AGENTS.md (1)

394-401: Add language specifier to fenced code block.

The file organization tree should have a language specifier (e.g., text or plaintext) for markdown linting compliance.

Proposed fix
-```
+```text
 wacore/src/iq/
 ├── mod.rs          # Re-exports
 ├── spec.rs         # IqSpec trait definition
 ├── node.rs         # Helper functions (required_child, required_attr, optional_attr)
 ├── groups.rs       # Group types, enums, newtypes, ProtocolNode & IqSpec impls
 └── blocklist.rs    # Blocklist types, ProtocolNode & IqSpec impls
</details>

</blockquote></details>
<details>
<summary>wacore/src/iq/usync.rs (3)</summary><blockquote>

`162-177`: **Significant code duplication: phone formatting logic repeated across specs.**

The phone number formatting logic (adding `+` prefix) is duplicated in `IsOnWhatsAppSpec::build_iq` (lines 166-176) and `ContactInfoSpec::build_iq` (lines 263-275). Consider extracting to a helper function.


<details>
<summary>Proposed refactor</summary>

```rust
// Add helper function at module level
fn build_phone_user_nodes(phones: &[String]) -> Vec<Node> {
    phones
        .iter()
        .map(|phone| {
            let phone_content = if phone.starts_with('+') {
                phone.clone()
            } else {
                format!("+{}", phone)
            };
            NodeBuilder::new("user")
                .children(vec![NodeBuilder::new("contact")
                    .string_content(phone_content)
                    .build()])
                .build()
        })
        .collect()
}

Then use in both specs:

-        let user_nodes: Vec<Node> = self
-            .phones
-            .iter()
-            .map(|phone| {
-                let phone_content = if phone.starts_with('+') {
-                    phone.clone()
-                } else {
-                    format!("+{}", phone)
-                };
-                NodeBuilder::new("user")
-                    .children(vec![NodeBuilder::new("contact")
-                        .string_content(phone_content)
-                        .build()])
-                    .build()
-            })
-            .collect();
+        let user_nodes = build_phone_user_nodes(&self.phones);

602-627: Consider using the helper functions from node.rs for consistent parsing.

The DeviceListSpec::parse_response manually handles attribute extraction and error messages. Consider using required_jid and required_attr from wacore/src/iq/node.rs for consistency with the documented patterns.

Example refactor for JID extraction
+use crate::iq::node::required_jid;

-            let user_jid = user_node
-                .attrs()
-                .optional_jid("jid")
-                .ok_or_else(|| anyhow!("user node missing required 'jid' attribute"))?;
+            let user_jid = required_jid(user_node, "jid")?;

624-627: Missing <device-list> causes entire parse to fail for all users.

If one user is missing the <device-list> node, the entire response parsing fails. Consider logging a warning and skipping that user instead, similar to how other specs handle missing optional data.

Proposed fix for resilient parsing
-            let device_list_node = user_node
+            let device_list_node = match user_node
                 .get_optional_child_by_tag(&["devices", "device-list"])
-                .ok_or_else(|| anyhow!("<device-list> not found for user {user_jid}"))?;
+            {
+                Some(node) => node,
+                None => {
+                    log::warn!("<device-list> not found for user {user_jid}, skipping");
+                    continue;
+                }
+            };
src/features/mex.rs (1)

61-76: Clarify the purpose of the duplicate fatal error check.

The comment on line 66 states "the IqSpec already checks, but we want to return our error type." If IqSpec returns an error on fatal responses, this check would never be reached. If it doesn't, the comment is misleading.

Consider either:

  1. Removing the comment if IqSpec doesn't check for fatal errors
  2. Clarifying that this converts successful responses with fatal errors to MexError
wacore/src/iq/blocklist.rs (2)

79-92: Consider logging when defaulting to "block" for missing action attribute.

Line 84 silently defaults to "block" when the action attribute is missing. While this may be intentional for backwards compatibility, consider adding a debug/warn log to surface unexpected protocol responses during troubleshooting.

♻️ Suggested improvement
     fn try_from_node(node: &Node) -> Result<Self> {
         if node.tag != "item" {
             return Err(anyhow!("expected <item>, got <{}>", node.tag));
         }

-        let action_str = optional_attr(node, "action").unwrap_or("block");
+        let action_str = optional_attr(node, "action").unwrap_or_else(|| {
+            warn!(target: "blocklist", "Missing action attribute, defaulting to 'block'");
+            "block"
+        });
         let action = BlocklistAction::try_from(action_str)?;

162-174: Silent entry drops may cause incomplete blocklist data.

The filter_map silently discards entries that fail to parse, logging only a warning. The caller has no indication that some entries were dropped, which could lead to incorrect assumptions about the blocklist state (e.g., thinking a contact isn't blocked when parsing simply failed).

Consider either:

  1. Collecting errors and returning a result with warnings
  2. Returning Result<Vec<BlocklistEntry>> that fails on first parse error
♻️ Alternative: fail-fast approach
         let entries = items
             .iter()
-            .filter_map(|item| match BlocklistEntry::try_from_node(item) {
-                Ok(entry) => Some(entry),
-                Err(e) => {
-                    warn!(
-                        target: "blocklist",
-                        "Failed to parse blocklist entry: {e}"
-                    );
-                    None
-                }
-            })
-            .collect();
+            .map(|item| BlocklistEntry::try_from_node(item))
+            .collect::<Result<Vec<_>>>()?;
wacore/src/protocol.rs (1)

41-100: Macro only supports String fields; consider documenting this limitation.

The macro assumes all fields are String due to:

  • Line 78: self.$field.to_string() in into_node
  • Line 89: s.to_string() in try_from_node
  • Line 98: $default.to_string() in the default helper

This is reasonable for attribute-based nodes, but users may expect support for other types (e.g., u64, bool). Consider adding a note in the doc comment.

📝 Documentation suggestion
 /// Macro for defining simple protocol nodes with only attributes (no children).
 ///
 /// This macro generates a struct with the specified fields as attributes,
 /// and implements the `ProtocolNode` trait for it.
+///
+/// **Note:** All fields must be `String` type. Attributes are serialized via
+/// `to_string()` and deserialized via `optional_string()`.
 ///
 /// # Example
src/features/blocking.rs (1)

48-52: Consider caching or documenting the cost of is_blocked.

This method fetches the entire blocklist for each check, incurring a network round-trip and O(n) iteration. If called frequently (e.g., in a loop checking multiple contacts), this becomes expensive.

Consider:

  1. Documenting the cost in the method's doc comment
  2. Offering a batch variant that accepts multiple JIDs
  3. Adding optional client-side caching if the blocklist is relatively stable
📝 Documentation suggestion
     /// Check if a contact is blocked.
+    ///
+    /// **Note:** This fetches the full blocklist on each call. For batch checks,
+    /// prefer calling `get_blocklist()` once and filtering locally.
     pub async fn is_blocked(&self, jid: &Jid) -> Result<bool> {

Comment thread wacore/Cargo.toml Outdated
Comment thread wacore/derive/src/lib.rs Outdated
Comment thread wacore/src/iq/mex.rs
Comment thread wacore/src/iq/mex.rs Outdated
Comment thread wacore/src/types/message.rs
@Salientekill

Copy link
Copy Markdown
Contributor

vc é simplesmente foda

@jlucaso1
jlucaso1 force-pushed the refactor-stanza-architecture branch from f348791 to e3d136d Compare January 23, 2026 12:51

@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: 1

🤖 Fix all issues with AI agents
In `@wacore/src/iq/mediaconn.rs`:
- Around line 64-88: The parse_response implementation for media_conn must
validate required attributes: replace usages of attrs.string("auth") and
host_node.attrs().string("hostname") with the required-attribute pattern (use
optional_string() and return an error when None) following the required_attr()
pattern in wacore::iq::node; ensure parse_response returns an anyhow::Error when
"auth" or any host "hostname" is missing and propagate those values into
MediaConnResponse and MediaConnHost accordingly.
♻️ Duplicate comments (2)
wacore/src/iq/mex.rs (1)

106-121: Consider adding a safety comment for the serialization.

Per the previous review, serde_json::to_vec on a MexPayload containing a &serde_json::Value is effectively infallible (only custom serializers or non-string map keys could cause failure, neither of which applies here). Consider adding a brief comment documenting this assumption to clarify why unwrap_or_default() is acceptable.

📝 Suggested documentation
         let payload = MexPayload {
             variables: &self.variables,
         };
+        // Safety: MexPayload wraps &serde_json::Value, and serde_json::to_vec
+        // cannot fail for Value (no custom serializers or non-string map keys).
         let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default();
wacore/src/types/message.rs (1)

6-13: LGTM! The #[serde(rename_all = "lowercase")] attribute correctly addresses the serialization consistency concern.

The serde attribute ensures serialization output matches as_str() and TryFrom expectations.

🧹 Nitpick comments (8)
wacore/src/iq/mex.rs (1)

28-36: Redundant #[serde(default)] on Option field.

The #[serde(default)] attribute on is_retryable is unnecessary since Option<T> fields already deserialize to None when absent. Either remove it for consistency with the other fields, or add it to all fields if you want to be explicit.

♻️ Suggested fix
 pub struct MexErrorExtensions {
     pub error_code: Option<i32>,
     pub is_summary: Option<bool>,
-    #[serde(default)]
     pub is_retryable: Option<bool>,
     pub severity: Option<String>,
 }
wacore/src/iq/usync.rs (1)

131-142: Inconsistent picture_id types between ContactInfo and UserInfo.

ContactInfo.picture_id is Option<u64> while UserInfo.picture_id is Option<String>. If these represent the same server field, consider unifying the type. If intentionally different due to distinct wire formats, a clarifying comment would help.

wacore/src/iq/blocklist.rs (1)

64-76: Consider logging when action attribute is missing.

Line 69 silently defaults to "block" when the action attribute is missing. While this may be acceptable for robustness, consider logging a warning to help diagnose malformed responses.

♻️ Suggested improvement
     fn try_from_node(node: &Node) -> Result<Self> {
         if node.tag != "item" {
             return Err(anyhow!("expected <item>, got <{}>", node.tag));
         }

-        let action_str = optional_attr(node, "action").unwrap_or("block");
+        let action_str = optional_attr(node, "action").unwrap_or_else(|| {
+            log::warn!(target: "blocklist", "missing 'action' attribute, defaulting to 'block'");
+            "block"
+        });
         let action = BlocklistAction::try_from(action_str)?;
         let jid_str = optional_attr(node, "jid").ok_or_else(|| anyhow!("missing jid attribute"))?;
         let jid = jid_str.parse()?;

         Ok(Self { jid, action })
     }
src/features/blocking.rs (2)

43-48: Inconsistent return type with other methods.

get_blocklist returns Result<Vec<BlocklistEntry>> (anyhow) while block and unblock return Result<(), IqError>. Consider using IqError consistently for all IQ operations.

♻️ Suggested fix
     /// Get the full blocklist.
-    pub async fn get_blocklist(&self) -> Result<Vec<BlocklistEntry>> {
+    pub async fn get_blocklist(&self) -> Result<Vec<BlocklistEntry>, IqError> {
         debug!(target: "Blocking", "Fetching blocklist...");
         let entries = self.client.execute(GetBlocklistSpec).await?;
         debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len());
         Ok(entries)
     }

50-54: is_blocked fetches entire blocklist for a single check.

This approach requires a network round-trip and O(n) iteration for each check. If this is called frequently, consider caching the blocklist or implementing a server-side check if the protocol supports it.

wacore/src/iq/prekeys.rs (1)

80-92: Inconsistent error handling for missing <count> node vs missing value attribute.

The code returns an error if the <count> node is missing (line 83), but silently defaults to 0 if the value attribute is missing or unparseable (lines 87-88). Consider either being consistently lenient (return 0 if <count> is missing) or consistently strict (return error for missing/invalid value).

♻️ Option A: Be consistently lenient
     fn parse_response(&self, response: &Node) -> Result<Self::Response, anyhow::Error> {
-        let count_node = response
-            .get_optional_child("count")
-            .ok_or_else(|| anyhow!("Missing <count> node in response"))?;
-
-        // Server may return <count/> without value attribute when count is 0,
-        // or return an unparseable value. Default to 0 in these cases.
-        let count_str = count_node.attrs().optional_string("value").unwrap_or("0");
-        let count = count_str.parse::<usize>().unwrap_or(0);
+        let count = response
+            .get_optional_child("count")
+            .and_then(|n| n.attrs().optional_string("value"))
+            .and_then(|s| s.parse::<usize>().ok())
+            .unwrap_or(0);

         Ok(PreKeyCountResponse { count })
     }
wacore/src/iq/mediaconn.rs (2)

26-30: Consider adding PartialEq, Eq derives.

These structs would benefit from PartialEq and Eq derives for easier comparison in tests and potential use in collections.

♻️ Optional enhancement
 /// Media connection host information.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct MediaConnHost {
     pub hostname: String,
 }

91-153: Good test coverage with room for edge cases.

The tests cover the main happy path and the missing node error case well. Consider adding tests for edge cases like missing auth attribute or empty hosts list to ensure robust error handling.

💡 Optional: Additional edge case tests
#[test]
fn test_media_conn_spec_parse_response_empty_hosts() {
    let spec = MediaConnSpec::new();

    let response = NodeBuilder::new("iq")
        .attr("type", "result")
        .children([NodeBuilder::new("media_conn")
            .attr("auth", "test-auth-token")
            .attr("ttl", "3600")
            .build()])
        .build();

    let result = spec.parse_response(&response).unwrap();
    assert!(result.hosts.is_empty());
}

#[test]
fn test_media_conn_spec_parse_response_missing_ttl() {
    let spec = MediaConnSpec::new();

    let response = NodeBuilder::new("iq")
        .attr("type", "result")
        .children([NodeBuilder::new("media_conn")
            .attr("auth", "test-auth-token")
            .build()])
        .build();

    let result = spec.parse_response(&response).unwrap();
    assert_eq!(result.ttl, 0); // defaults to 0
}

Comment thread wacore/src/iq/mediaconn.rs

@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: 3

🤖 Fix all issues with AI agents
In `@src/features/blocking.rs`:
- Around line 50-54: The is_blocked function currently checks only jid.user
which causes false positives; update is_blocked to compare full JIDs from
get_blocklist() against the provided Jid (e.g., compare e.jid == *jid or their
string/full representation) so the resource and domain are considered; locate
the is_blocked method and replace the .jid.user comparison with a full Jid
equality/identity check that uses the Jid type's equality or canonical string
form.

In `@wacore/src/iq/blocklist.rs`:
- Around line 64-72: The code currently defaults a missing action attribute to
"block" in try_from_node which can cause silent unintended blocking; change the
retrieval of the action attribute (where action_str is set via optional_attr) to
fail if missing (similar to how jid_str is handled) by returning an Err with a
clear message, then parse that value with BlocklistAction::try_from as before
(i.e., replace the unwrap_or("block") usage with an ok_or_else(...) error path
so try_from_node returns an error when action is absent).

In `@wacore/tests/noise_handshake_test.rs`:
- Around line 176-186: The test currently drops the Result returned by
nh.authenticate(...) which can hide failures and trigger must_use warnings;
update the calls to nh.authenticate(&client_eph_pub) and
nh.authenticate(&server_eph_pub) to handle the Result explicitly (e.g., call
.expect("authenticate failed at step X") or unwrap with a clear message) so test
failures point to the exact authenticate invocation; keep assertions on
nh.hash() and related variables (hash_after_auth_client_eph) unchanged.
♻️ Duplicate comments (1)
wacore/src/iq/mediaconn.rs (1)

64-79: Validate required attributes instead of defaulting to empty strings.
auth and hostname are required; using attrs.string() silently accepts missing data.

🔧 Proposed fix
-        let mut attrs = media_conn_node.attrs();
-        let auth = attrs.string("auth");
+        let mut attrs = media_conn_node.attrs();
+        let auth = attrs
+            .optional_string("auth")
+            .ok_or_else(|| anyhow!("Missing auth attribute in media_conn response"))?
+            .to_string();
         let ttl = attrs.optional_u64("ttl").unwrap_or(0);
         let max_buckets = attrs.optional_u64("max_buckets");

-        let hosts = media_conn_node
-            .get_children_by_tag("host")
-            .iter()
-            .map(|host_node| MediaConnHost {
-                hostname: host_node.attrs().string("hostname"),
-            })
-            .collect();
+        let hosts = media_conn_node
+            .get_children_by_tag("host")
+            .iter()
+            .map(|host_node| {
+                let hostname = host_node
+                    .attrs()
+                    .optional_string("hostname")
+                    .ok_or_else(|| anyhow!("Missing hostname attribute in media_conn response"))?
+                    .to_string();
+                Ok(MediaConnHost { hostname })
+            })
+            .collect::<Result<Vec<_>, anyhow::Error>>()?;
🧹 Nitpick comments (2)
wacore/src/iq/usync.rs (2)

54-93: Consider using StringEnum derive for consistency.

The UsyncMode and UsyncContext enums manually implement as_str(), but other similar enums in this PR (e.g., MemberAddMode, MemberLinkMode in wacore/src/iq/groups.rs) use the StringEnum derive macro. Consider aligning for consistency, though the current implementation is functionally correct.


124-142: Type inconsistency: picture_id is u64 in ContactInfo but String in UserInfo.

ContactInfo.picture_id is Option<u64> while UserInfo.picture_id is Option<String>. While this may reflect different wire formats, it could confuse consumers using both types. Consider documenting this difference or unifying to String (the more flexible type) if the protocol allows.

Comment thread src/features/blocking.rs
Comment thread wacore/src/iq/blocklist.rs
Comment thread wacore/tests/noise_handshake_test.rs
@jlucaso1
jlucaso1 force-pushed the refactor-stanza-architecture branch from e3d136d to 398a430 Compare January 23, 2026 13:25

@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

🤖 Fix all issues with AI agents
In `@wacore/src/iq/groups.rs`:
- Around line 397-407: The participant parsing currently aborts the whole group
on any error from ParticipantType::try_from; change try_from_node so it attempts
to parse the optional "type" string via ParticipantType::try_from but on failure
(unknown/unrecognized type) it defaults participant_type to
ParticipantType::Member instead of returning Err—ensure genuine parse errors
unrelated to unknown type still propagate if needed, and update the
participant_type assignment (the call site using
ParticipantType::try_from(node.attrs().optional_string("type")) in
try_from_node) to catch the Err and assign ParticipantType::Member as a safe
fallback.
- Around line 266-347: The create-node builder uses raw options.participants
which can include phone_number for non-LID JIDs; call normalize_participants to
sanitize participants before constructing participant nodes. In
build_create_group_node replace iterating over &options.participants with
normalize_participants(&options.participants) (e.g., for participant in
normalize_participants(&options.participants) { ... }) so phone_number is
dropped for non-LID JIDs and payloads are safe by default.
♻️ Duplicate comments (4)
wacore/tests/noise_handshake_test.rs (1)

176-186: Handle authenticate Result explicitly to surface failures.
Dropping the Result can hide the failure point in this test. Please add an expect(...) on both calls.

wacore/derive/src/lib.rs (1)

130-133: Required attributes still silently default to empty strings.

This was flagged in a previous review: when info.default is None, the field is considered required, but string() returns "" on missing attributes instead of erroring. Invalid nodes will parse successfully with empty required fields.

Use optional_string() with error propagation:

Suggested fix
             } else {
                 quote! {
-                    `#field_ident`: node.attrs().string(`#attr_name`).to_string()
+                    `#field_ident`: node.attrs().optional_string(`#attr_name`)
+                        .ok_or_else(|| anyhow::anyhow!("missing required attribute: {}", `#attr_name`))?
+                        .to_string()
                 }
             }
wacore/src/iq/blocklist.rs (1)

69-73: Defaulting missing action attribute to "block" may cause unintended behavior.

While the warning log was added, silently defaulting to "block" when the attribute is missing could still lead to unexpected blocking operations. Consider failing the parse instead.

🔧 Proposed fix
-        let action_str = optional_attr(node, "action").unwrap_or_else(|| {
-            warn!(target: "blocklist", "missing 'action' attribute, defaulting to 'block'");
-            "block"
-        });
+        let action_str = optional_attr(node, "action")
+            .ok_or_else(|| anyhow!("missing 'action' attribute"))?;
src/features/blocking.rs (1)

50-54: Compare full JIDs to avoid false positives.

Comparing only jid.user may incorrectly report blocked status for different JID variants (e.g., different servers or resources).

🔧 Proposed fix
     pub async fn is_blocked(&self, jid: &Jid) -> Result<bool> {
         let blocklist = self.get_blocklist().await?;
-        Ok(blocklist.iter().any(|e| e.jid.user == jid.user))
+        Ok(blocklist.iter().any(|e| &e.jid == jid))
     }
🧹 Nitpick comments (9)
wacore/src/iq/mediaconn.rs (2)

77-85: Consider validating that at least one host is returned.

The wire format shows multiple hosts, but no validation ensures hosts is non-empty. Downstream code (media upload/download) likely requires at least one host to function. If the server returns zero hosts, it may be better to fail fast here rather than later.

♻️ Optional validation
         for host_node in media_conn_node.get_children_by_tag("host") {
             let hostname = host_node
                 .attrs()
                 .optional_string("hostname")
                 .ok_or_else(|| anyhow!("Missing 'hostname' attribute in host node"))?
                 .to_string();
             hosts.push(MediaConnHost { hostname });
         }
+
+        if hosts.is_empty() {
+            return Err(anyhow!("No hosts returned in media_conn response"));
+        }

         Ok(MediaConnResponse {

149-157: Consider adding tests for missing required attributes.

The current tests cover the happy path and missing media_conn node. Consider adding tests for:

  • Missing auth attribute (should error)
  • Missing hostname in a host node (should error)

These would validate the error handling added per the previous review.

💚 Additional test cases
#[test]
fn test_media_conn_spec_parse_response_missing_auth() {
    let spec = MediaConnSpec::new();

    let response = NodeBuilder::new("iq")
        .attr("type", "result")
        .children([NodeBuilder::new("media_conn")
            .attr("ttl", "3600")
            .build()])
        .build();

    let result = spec.parse_response(&response);
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("auth"));
}

#[test]
fn test_media_conn_spec_parse_response_missing_hostname() {
    let spec = MediaConnSpec::new();

    let response = NodeBuilder::new("iq")
        .attr("type", "result")
        .children([NodeBuilder::new("media_conn")
            .attr("auth", "test-auth")
            .attr("ttl", "3600")
            .children([NodeBuilder::new("host").build()])
            .build()])
        .build();

    let result = spec.parse_response(&response);
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("hostname"));
}
wacore/src/iq/mex.rs (1)

137-145: Consider whether callers might need access to the full response even on fatal errors.

When a fatal error is detected, the parsed MexResponse is discarded and only the error message is returned. If callers ever need to inspect partial data or other errors alongside the fatal error, this design prevents that.

Current behavior is reasonable for most use cases. If future requirements need full response access on fatal errors, consider returning a custom error type that includes the MexResponse.

wacore/derive/src/lib.rs (2)

253-270: Silently swallowed parse errors degrade DX for malformed attributes.

When #[protocol(tag)] is written without = "value", the parse error is swallowed by let _ = ... and the user sees a generic "requires #[protocol(tag = "...")]" message instead of the actual parse failure. Consider propagating or logging the parse error for better diagnostics.

Suggested improvement
 fn extract_tag(attrs: &[syn::Attribute]) -> Option<String> {
     for attr in attrs {
         if attr.path().is_ident("protocol") {
             let mut tag = None;
-            let _ = attr.parse_nested_meta(|meta| {
+            if let Err(e) = attr.parse_nested_meta(|meta| {
                 if meta.path.is_ident("tag") {
                     let value: syn::LitStr = meta.value()?.parse()?;
                     tag = Some(value.value());
                 }
                 Ok(())
-            });
+            }) {
+                // Could emit a compile error here with the specific parse failure
+                // For now, fall through to the generic error
+                let _ = e;
+            }
             if tag.is_some() {
                 return tag;
             }
         }
     }
     None
 }

358-385: Malformed #[str] attributes produce misleading errors.

If a user writes #[str(value = "foo")] or #[str = 123] (non-string literal), the if-let chain on lines 361-366 silently fails to match, and the error message on line 375 says "requires #[str = "..."]" — implying the attribute is missing when it's actually malformed.

Consider adding explicit validation for the attribute format to produce a clearer error message.

wacore/src/iq/blocklist.rs (1)

143-155: Consider whether silently skipping malformed entries is desired.

Using filter_map with a warning log allows the response to partially succeed when some entries fail to parse. This is resilient but may hide protocol issues. If strict parsing is preferred, consider collecting errors and failing if any entry is invalid.

wacore/src/iq/prekeys.rs (1)

85-91: Defensive parsing may hide protocol issues.

Using unwrap_or("0") and unwrap_or(0) silently defaults to 0 for missing or unparsable values. While the comment explains this is intentional for server edge cases, consider logging when defaults are applied to aid debugging.

🔧 Suggested improvement with logging
         // Server may return <count/> without value attribute when count is 0,
         // or return an unparseable value. Default to 0 in these cases.
-        let count_str = count_node.attrs().optional_string("value").unwrap_or("0");
-        let count = count_str.parse::<usize>().unwrap_or(0);
+        let count_str = count_node.attrs().optional_string("value").unwrap_or_else(|| {
+            log::debug!(target: "prekeys", "Missing 'value' attribute in <count>, defaulting to 0");
+            "0"
+        });
+        let count = count_str.parse::<usize>().unwrap_or_else(|_| {
+            log::warn!(target: "prekeys", "Unparseable count value: {}, defaulting to 0", count_str);
+            0
+        });
wacore/src/iq/contacts.rs (1)

108-119: Consider distinguishing 401 (unauthorized) from 404 (not found).

Currently both error codes 401 and 404 return Ok(None). While 404 means the picture doesn't exist, 401 typically indicates authorization failure. Depending on the protocol semantics, you might want to return an error for 401 to signal the caller that access was denied rather than the picture being absent.

🔧 Proposed alternative handling
         // Check for error response
         if let Some(error_node) = picture_node.get_optional_child("error") {
             let code = error_node.attrs().optional_string("code").unwrap_or("0");
-            if code == "404" || code == "401" {
+            if code == "404" {
                 return Ok(None);
             }
+            if code == "401" {
+                return Err(anyhow!("Unauthorized to access profile picture"));
+            }
             let text = error_node
                 .attrs()
                 .optional_string("text")
                 .unwrap_or("unknown error");
             return Err(anyhow!("Profile picture error {}: {}", code, text));
         }
wacore/src/iq/usync.rs (1)

168-190: Consider extracting a shared builder for the common usync envelope.

The four build_iq methods repeat the same usync wrapper (sid/mode/last/index/context + list). A small helper would reduce duplication and keep defaults consistent.

Also applies to: 241-269, 357-396, 525-558

Comment thread wacore/src/iq/groups.rs
Comment thread wacore/src/iq/groups.rs

@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: 4

🤖 Fix all issues with AI agents
In `@src/features/blocking.rs`:
- Line 8: Remove the `use anyhow::Result;` import and update the function return
types that currently use `Result<(), IqError>` to explicit results: for
functions that should return an anyhow error use `anyhow::Result<()>`, and for
those that need to preserve `IqError` use `std::result::Result<(), IqError>`;
search for the occurrences of `Result<(), IqError>` in this file (the functions
around the tokens on lines previously flagged) and replace their return type
accordingly, keeping the `IqError` type where intended and using
`anyhow::Result` for generic anyhow errors.

In `@src/message.rs`:
- Around line 1141-1151: parse_message_info currently falls back to an empty id
which causes cache/key collisions; instead make the missing "id" an error: in
parse_message_info (where attrs.optional_string("id") is used) return
Err(anyhow::anyhow!("missing 'id' attribute'")) when id is None and do not
construct MessageInfo with an empty string; update any callers/tests that expect
parsing to succeed for messages lacking id and add a unit test for
parse_message_info to assert that a missing id yields an error to prevent silent
collisions in cache/keys.

In `@wacore/derive/src/lib.rs`:
- Around line 153-173: The generated impl uses a crate-relative path
"crate::protocol::ProtocolNode" which breaks when the macro is used from
external crates; update the quote! in lib.rs so the impl refers to the absolute
path "::wacore::protocol::ProtocolNode" (and similarly convert any other
crate-relative paths in the same expanded block if present) so the generated
code resolves correctly when the macro is invoked from outside the wacore
workspace.

In `@wacore/src/usync.rs`:
- Around line 76-85: The code currently uses device_id_str.parse()? which
propagates parse errors and aborts parsing; update the loop that iterates
device_list_node and device_node to handle non-numeric or out-of-range IDs by
attempting device_id_str.parse::<u16>() in a match (or map_err) and on Err log a
warn (target: "usync") stating the device id is invalid and skip that device
(continue) instead of returning an error; keep the existing behavior for missing
id (attrs().optional_string("id")) unchanged and only change the parsing of
device_id.
♻️ Duplicate comments (1)
wacore/src/iq/blocklist.rs (1)

64-76: LGTM! Previous issue addressed.

The action attribute is now properly required with explicit error handling via ok_or_else, eliminating the previous issue of silently defaulting to "block" when the attribute was missing.

🧹 Nitpick comments (8)
src/receipt.rs (1)

16-20: Consider adding context to the missing-id warning.

Including from (and possibly receipt_type_str or node info) would make malformed receipts easier to trace in logs.

♻️ Suggested tweak
-            None => {
-                log::warn!("Receipt stanza missing required 'id' attribute");
+            None => {
+                log::warn!("Receipt stanza missing required 'id' attribute (from: {from})");
                 return;
             }
wacore/appstate/src/patch_decode.rs (2)

62-65: Consider using required_string if available.

The pattern optional_string(...).ok_or_else(...) works but is verbose for a required field. If the attribute API provides a required_string method (given the commit message mentions "required or optional" variants), using it would be more direct:

let name_str = ag.required_string("name")?.to_string();

If required_string isn't available yet, the current approach is fine.


32-44: Redundant unwrap_orfrom_str never fails.

The FromStr impl always returns Ok(...) with the _ => Self::Unknown catch-all, so unwrap_or(WAPatchName::Unknown) on line 97 is dead code. You can simplify to just .unwrap() (infallible) or restructure to avoid the confusion.

Alternatively, if you want from_str to actually fail on unknown values, return Err(()) in the catch-all and keep the unwrap_or.

Option A: Simplify call site (since from_str is infallible)
-        name: WAPatchName::from_str(&name_str).unwrap_or(WAPatchName::Unknown),
+        name: WAPatchName::from_str(&name_str).unwrap(), // infallible
Option B: Make from_str fallible and keep unwrap_or
 impl FromStr for WAPatchName {
     type Err = ();
     fn from_str(s: &str) -> Result<Self, Self::Err> {
-        Ok(match s {
+        match s {
             "critical_block" => Self::CriticalBlock,
             "critical_unblock_low" => Self::CriticalUnblockLow,
             "regular_low" => Self::RegularLow,
             "regular_high" => Self::RegularHigh,
             "regular" => Self::Regular,
-            _ => Self::Unknown,
-        })
+            _ => Err(()),
+        }
     }
 }

Also applies to: 97-97

src/spam_report.rs (1)

43-78: Consider migrating tests to the non-deprecated attribute API.

The #[allow(deprecated)] suppresses warnings for the deprecated .string() method. Per the commit message, the new API uses .required() or .optional() variants. Consider updating these test assertions to use the new API for consistency and to serve as documentation of the preferred approach.

For example:

-        assert_eq!(node.attrs().string("spam_flow"), "MessageMenu");
+        assert_eq!(node.attrs().required("spam_flow"), "MessageMenu");
wacore/src/iq/groups.rs (2)

196-264: Consider validating subject before sending the create request.

GroupCreateOptions uses raw String for subject while GroupSubject provides length validation. An empty or over-length subject will be rejected by the server. Consider validating in GroupCreateIq::build_iq or requiring GroupSubject in the options.

♻️ Option A: Validate in build_iq
 impl IqSpec for GroupCreateIq {
     type Response = Jid;

     fn build_iq(&self) -> InfoQuery<'static> {
+        // Consider returning Result and validating subject here
+        // GroupSubject::new(&self.options.subject)?;
         InfoQuery::set(
♻️ Option B: Use GroupSubject in options
 pub struct GroupCreateOptions {
-    #[builder(setter(into))]
-    pub subject: String,
+    pub subject: GroupSubject,

449-480: Minor duplication in group ID parsing.

The pattern for parsing group IDs (lines 455-459) is duplicated in GroupCreateIq::parse_response (lines 646-650). Consider extracting to a helper for consistency.

♻️ Suggested helper
fn parse_group_jid(id_str: &str) -> Result<Jid> {
    if id_str.contains('@') {
        id_str.parse().map_err(Into::into)
    } else {
        Ok(Jid::group(id_str))
    }
}
wacore/derive/src/lib.rs (2)

140-151: Consider documenting or reconsidering Default behavior for required fields.

Fields without a default attribute (i.e., required) get String::new() in the generated Default impl. This creates instances where required fields are empty—values that would fail try_from_node validation if round-tripped. While this may be intentional for builder patterns, it could surprise users.

Consider either:

  1. Documenting this behavior in the macro docs, or
  2. Making Default derive opt-in via a separate attribute like #[protocol(derive_default)]

255-272: Consider surfacing parse errors for better diagnostics.

The let _ = attr.parse_nested_meta(...) pattern silently discards errors. If a user writes malformed syntax like #[protocol(tag = 123)], they'll see "requires #[protocol(tag = ...)]" rather than a specific parse error.

♻️ Suggested improvement
 fn extract_tag(attrs: &[syn::Attribute]) -> Option<String> {
     for attr in attrs {
         if attr.path().is_ident("protocol") {
             let mut tag = None;
-            let _ = attr.parse_nested_meta(|meta| {
+            if let Err(e) = attr.parse_nested_meta(|meta| {
                 if meta.path.is_ident("tag") {
                     let value: syn::LitStr = meta.value()?.parse()?;
                     tag = Some(value.value());
                 }
                 Ok(())
-            });
+            }) {
+                // Optionally: return Err(e) to surface parse errors
+                // For now, continue to next attribute
+            }
             if tag.is_some() {
                 return tag;
             }
         }
     }
     None
 }

Comment thread src/features/blocking.rs Outdated
Comment thread src/message.rs Outdated
Comment thread wacore/derive/src/lib.rs
Comment thread wacore/src/usync.rs

@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: 1

🤖 Fix all issues with AI agents
In `@AGENTS.md`:
- Around line 394-401: Update the fenced code block that starts with the
directory listing "wacore/src/iq/" to include a language tag by changing the
opening ``` to ```text (so the block becomes ```text ... ```), which satisfies
markdownlint MD040; leave the closing ``` as-is and ensure no other characters
are added to the block fence.
🧹 Nitpick comments (1)
wacore/src/iq/groups.rs (1)

143-199: Enforce group-create limits before building the IQ.

GroupCreateOptions.subject is a raw String, and participant count isn’t constrained, so callers can construct invalid payloads. Consider switching subject to GroupSubject (or adding a lightweight validation helper) so oversize subjects or >GROUP_SIZE_LIMIT participants fail fast and align with the type-safe architecture.

Comment thread AGENTS.md
Comment on lines +394 to +401
```
wacore/src/iq/
├── mod.rs # Re-exports
├── spec.rs # IqSpec trait definition
├── node.rs # Helper functions (required_child, required_attr, optional_attr)
├── groups.rs # Group types, enums, newtypes, ProtocolNode & IqSpec impls
└── blocklist.rs # Blocklist types, ProtocolNode & IqSpec impls
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a language tag to the fenced block.

markdownlint MD040 flags this block; adding a language keeps the docs lint-clean.

💡 Suggested fix
-```
+```text
 wacore/src/iq/
 ├── mod.rs          # Re-exports
 ├── spec.rs         # IqSpec trait definition
 ├── node.rs         # Helper functions (required_child, required_attr, optional_attr)
 ├── groups.rs       # Group types, enums, newtypes, ProtocolNode & IqSpec impls
 └── blocklist.rs    # Blocklist types, ProtocolNode & IqSpec impls
-```
+```
📝 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
```
wacore/src/iq/
├── mod.rs # Re-exports
├── spec.rs # IqSpec trait definition
├── node.rs # Helper functions (required_child, required_attr, optional_attr)
├── groups.rs # Group types, enums, newtypes, ProtocolNode & IqSpec impls
└── blocklist.rs # Blocklist types, ProtocolNode & IqSpec impls
```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

394-394: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@AGENTS.md` around lines 394 - 401, Update the fenced code block that starts
with the directory listing "wacore/src/iq/" to include a language tag by
changing the opening ``` to ```text (so the block becomes ```text ... ```),
which satisfies markdownlint MD040; leave the closing ``` as-is and ensure no
other characters are added to the block fence.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/client.rs (1)

1541-1548: Avoid sending a pong with an empty id when the incoming ping lacks one.

An empty id can violate IQ semantics. Prefer omitting the attribute if it’s absent.

💡 Suggested fix
-            let id = parser.optional_string("id").unwrap_or("").to_string();
-            let pong = NodeBuilder::new("iq")
-                .attrs([
-                    ("to", from_jid.to_string()),
-                    ("id", id),
-                    ("type", "result".to_string()),
-                ])
-                .build();
+            let mut builder = NodeBuilder::new("iq")
+                .attr("to", from_jid.to_string())
+                .attr("type", "result");
+            if let Some(id) = parser.optional_string("id") {
+                builder = builder.attr("id", id);
+            }
+            let pong = builder.build();
src/prekeys.rs (1)

114-131: Offload key generation to avoid blocking the async runtime.

While KeyPair::generate(&mut OsRng.unwrap_err()) is the correct RNG construction for Signal Protocol, generating 50 key pairs in the async upload_pre_keys function will stall the runtime. Wrap the loop in tokio::task::spawn_blocking as per coding guidelines for CPU-bound work.

🔧 Proposed fix
+            let (key_pair, pre_key_record) = tokio::task::spawn_blocking(move || {
+                use rand::TryRngCore;
+                let key_pair = KeyPair::generate(&mut OsRng.unwrap_err());
+                let pre_key_record = new_pre_key_record(pre_key_id, &key_pair);
+                (key_pair, pre_key_record)
+            })
+            .await
+            .map_err(|e| anyhow::anyhow!("pre-key generation task failed: {e}"))?;
🤖 Fix all issues with AI agents
In `@wacore/src/iq/dirty.rs`:
- Around line 93-106: The single() constructor currently swallows parse errors
for the timestamp; change its signature to surface invalid timestamps by either
accepting Option<u64> instead of Option<&str> or returning Result<Self,
ParseError>; locate CleanDirtyBitsSpec::single and replace the current
ts.parse() branch so that parse failures produce an Err (or propagate the
invalid input error) rather than falling back to DirtyBit::new, and ensure
callers handle the Result (or pass a pre-parsed timestamp) before calling
DirtyBit::with_timestamp(DirtyType::from(dirty_type), ts_num).

Comment thread wacore/src/iq/dirty.rs

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/usync.rs (1)

39-57: Avoid logging phone numbers (PII) in LID mapping logs.

Both warn/debug lines emit mapping.phone_number, which is sensitive and can violate privacy/compliance expectations. Please redact or omit the phone number in logs.

🛡️ Proposed redaction
-                    warn!(
-                        "Failed to persist LID {} -> {} from usync: {err}",
-                        mapping.lid, mapping.phone_number,
-                    );
+                    warn!(
+                        "Failed to persist LID mapping for {} from usync: {err}",
+                        mapping.lid
+                    );
@@
-                debug!(
-                    "Learned LID mapping from usync: {} -> {}",
-                    mapping.lid, mapping.phone_number
-                );
+                debug!(
+                    "Learned LID mapping from usync for {}",
+                    mapping.lid
+                );
🤖 Fix all issues with AI agents
In `@src/handlers/message.rs`:
- Around line 30-39: The current extraction of chat_id using
node.attrs().optional_string("from") accepts empty strings which collapses all
empty-from stanzas into the same queue key; update the matching logic in
message.rs so that after calling node.attrs().optional_string("from") you treat
Some(id) where id.is_empty() as invalid: log a warning similar to the missing
case ("Message stanza has empty 'from' attribute") and return false; keep the
successful branch as chat_id = id.to_string(). This change should be applied to
the code around node.attrs().optional_string("from") and the chat_id binding.

In `@wacore/derive/src/lib.rs`:
- Around line 245-262: The extract_tag function swallows parse errors from
attr.parse_nested_meta, hiding malformed-attribute errors; change extract_tag to
return Result<Option<String>, syn::Error>, propagate and return any syn::Error
produced by parse_nested_meta instead of ignoring it, and update callers to
handle the Result (e.g., propagate with ? or convert to a user-facing error).
Specifically, modify extract_tag (and any call sites) so parse_nested_meta's
closure returns Err on bad literal types and the outer function forwards that
Err rather than discarding it.
🧹 Nitpick comments (2)
wacore/derive/src/lib.rs (2)

132-142: Default impl may create invalid protocol instances for required fields.

Required fields (those without default = "...") get String::new() in the Default impl. If users call Default::default() and use these instances directly, try_from_node would reject them due to missing required attributes.

Consider either:

  1. Not deriving Default when required fields exist, or
  2. Documenting that Default instances may not be protocol-valid

This is low-risk since Default is mostly used for builder patterns, but worth noting.


264-293: Same silent error pattern as extract_tag.

Line 272 also uses let _ = attr.parse_nested_meta(...). Apply the same fix pattern here for consistent error reporting.

Comment thread src/handlers/message.rs
Comment thread wacore/derive/src/lib.rs Outdated

@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

🤖 Fix all issues with AI agents
In `@wacore/derive/src/lib.rs`:
- Around line 351-387: The code currently allows multiple #[string_default]
attributes and simply overwrites default_variant; update the logic where
is_default is handled (the block that sets default_variant =
Some(variant_ident.clone())) to detect if default_variant.is_some() already and,
if so, return a syn::Error::new_spanned(...) converted via
to_compile_error().into() that reports "Multiple #[string_default] attributes
found; only one variant may be the default" (use variant_ident or the previously
set variant to span the error) so the compiler emits a clear error instead of
silently choosing the last one.
- Around line 274-297: The code currently treats a present #[attr(...)] without
a required name as Ok(None) which silently ignores malformed usage; update the
loop that iterates field.attrs (the block using attr.parse_nested_meta and the
subsequent if let Some(name) check) to emit a compile-time error instead: if the
attribute path is_ident("attr") and after parsing attr_name is None, return
Err(syn::Error::new_spanned(attr, "missing required `name` in
#[attr(...)]").into()); keep the successful branch that returns
Ok(Some(AttrFieldInfo { field_ident, attr_name: name, default })) unchanged.

Comment thread wacore/derive/src/lib.rs
Comment thread wacore/derive/src/lib.rs

@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: 1

🤖 Fix all issues with AI agents
In `@wacore/derive/src/lib.rs`:
- Around line 338-472: The derive_string_enum macro currently assumes all enum
variants are unit variants; update derive_string_enum to iterate each variant
(inside the for variant in variants loop) and check variant.fields is Unit
(i.e., matches syn::Fields::Unit), and if any variant has tuple or struct fields
emit a syn::Error::new_spanned on that variant_ident with a clear message like
"StringEnum only supports unit variants" (returning .to_compile_error().into());
ensure this check runs before using variant_ident in generated match arms so the
macro fails with a helpful error for non-unit variants.

@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: 1

🤖 Fix all issues with AI agents
In `@wacore/derive/src/lib.rs`:
- Around line 338-482: The macro derive_string_enum currently allows duplicate
#[str = "..."] values which makes the generated TryFrom<&str> ambiguous; modify
the collection loop that builds variant_infos (in derive_string_enum) to track
seen string values (e.g., with a temporary HashMap<String, Ident> or BTreeMap)
and if a str_val is already present emit and return a syn::Error::new_spanned
referencing the current variant_ident (and optionally the previously-seen
variant ident) explaining the duplicate; perform this duplicate check
immediately after determining str_val (before pushing into variant_infos) so
duplicates are rejected at expansion time and the try_from_arms generation
remains unchanged.

Comment thread wacore/derive/src/lib.rs

@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: 1

🤖 Fix all issues with AI agents
In `@wacore/derive/src/lib.rs`:
- Around line 135-145: The generated Default impl builds default_fields from
attr_fields and fills missing defaults with String::new(), which produces
invalid nodes for required attributes; change the generator to only emit the
Default implementation when all attr_fields have info.default present (e.g.,
check attr_fields.iter().all(|info| info.default.is_some())) and otherwise skip
generating Default for the type (or fall back to deriving Default only when
every field has a default); update the logic that builds default_fields (and the
surrounding code that emits the Default impl) to be conditional on that check so
fields with info.field_ident and info.default are only used when safe.

Comment thread wacore/derive/src/lib.rs Outdated
@jlucaso1
jlucaso1 merged commit 1ad50f2 into main Jan 23, 2026
8 checks passed
@jlucaso1
jlucaso1 deleted the refactor-stanza-architecture branch January 25, 2026 13:02
@coderabbitai coderabbitai Bot mentioned this pull request Feb 13, 2026
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.

2 participants