Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions tools/whatspec-codegen/src/emit/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ pub struct Wanted {
/// `(wire value, Rust identifier)`. `medianotify` is one word on the wire
/// and two in English, and nothing in the bundle says so.
pub renames: &'static [(&'static str, &'static str)],
/// The wire value that carries `#[wire_default]`, when the type has a
/// default that is not simply its first variant. Declared rather than
/// inferred because the catalog's variant order is not ours: reading the
/// default off position would silently change it the day upstream reorders.
pub default: Option<&'static str>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pub doc: &'static str,
}

Expand All @@ -69,6 +74,7 @@ pub const WANTED: &[Wanted] = &[
rust: "StanzaMessageType",
shape: Shape::Open,
renames: &[("medianotify", "MediaNotify")],
default: None,
doc: "The `type` attribute of an incoming `<message>` envelope.\n\
///\n\
/// The official parser rejects a stanza whose `type` is absent or\n\
Expand All @@ -82,6 +88,7 @@ pub const WANTED: &[Wanted] = &[
rust: "PollType",
shape: Shape::Closed,
renames: &[],
default: None,
doc: "The `polltype` attribute of an incoming `<message><meta>` node.\n\
///\n\
/// Closed on purpose: the attribute is `attrEnumOrNullIfUnknown`\n\
Expand All @@ -94,6 +101,7 @@ pub const WANTED: &[Wanted] = &[
rust: "EncMediaType",
shape: Shape::Open,
renames: &[("livelocation", "LiveLocation")],
default: None,
doc: "The `mediatype` attribute of an `<enc>` node.\n\
///\n\
/// A hint about the payload the ciphertext carries, available\n\
Expand All @@ -106,8 +114,66 @@ pub const WANTED: &[Wanted] = &[
rust: "RECEIPT_MODE",
shape: Shape::Masks,
renames: &[],
default: None,
doc: "Bits of a receipt's `<meta mode>` bitmask.",
},
Wanted {
module: "WAWebBackendJobs.flow",
name: "DecryptFailType",
rust: "DecryptFailMode",
shape: Shape::Closed,
renames: &[],
default: Some("show"),
doc: "The `decrypt-fail` attribute of an `<enc>` node.\n\
///\n\
/// `Hide` is the server asking that a failure to decrypt this\n\
/// stanza not be surfaced to the user.",
},
Wanted {
module: "WAWebSchemaGroupMetadata",
name: "MemberAddMode",
rust: "MemberAddMode",
shape: Shape::Closed,
renames: &[],
default: 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.

P2 Badge Pin defaults even when they currently lead the catalog

With default: None, WireEnum derives Default from whichever catalog variant happens to come first, so a future catalog reorder will silently change MemberAddMode::default() from AdminAdd; the same problem exists for CallLinkMedia at line 174, whose existing default is Audio. These hand-written types had stable defaults before this migration, and generated variants deliberately follow bundle ordering, so declare Some("admin_add") and Some("audio") just as the other migrated enums do.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

doc: "Who may add participants to a group.",
},
Wanted {
module: "WAWebGroupHistoryShareMode",
name: "MemberShareGroupHistoryMode",
rust: "MemberShareHistoryMode",
shape: Shape::Closed,
renames: &[],
default: Some("admin_share"),
doc: "Who may share a group's history with a new participant.",
},
Wanted {
module: "WAWebSetPrivacyJob",
name: "PrivacyUserAction",
rust: "DisallowedListAction",
shape: Shape::Closed,
renames: &[],
default: Some("add"),
doc: "Whether a privacy disallowed-list entry is being added or removed.",
},
Wanted {
module: "WAWebGroupApiConst",
name: "GROUP_PARTICIPANT_TYPES",
rust: "GroupParticipantType",
shape: Shape::Closed,
renames: &[("superadmin", "SuperAdmin")],
default: Some("participant"),
doc: "A participant's role in a group.",
},
Wanted {
module: "WAWebStatusSetupController",
name: "MediaType",
rust: "CallLinkMedia",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep call-link media independent of status media

When the status composer's MediaType changes independently—for example, by gaining a status-only kind—the next regeneration will incorrectly add that value to CallLinkMedia, even though this type is used to build and parse call-link stanzas in wacore/src/stanza/group_call.rs. WAWebStatusSetupController is not the call-link protocol, and the commit's identical-variant-set match does not establish that it owns the same wire enum; keep this type handwritten until the actual call-link source is identified, or bind that source instead.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, and this is the one binding in the sweep that was actually wrong. Reverted in b8e202aCallLinkMedia goes back to hand-written.

I checked the catalog before reverting: WAWebStatusSetupController::MediaType is the only entry whose variant set is exactly {audio, video}, and there is no call-link media enum anywhere in the 403, so there is nothing correct to bind instead. Meanwhile the type builds and parses <call_link> through build_call_link_create, build_call_link_join and parse_call_link_media. So the match was two unrelated two-valued media enums agreeing by coincidence, and a status-only kind added upstream would have landed in call-link stanzas on the next sync.

The underlying mistake is worth naming, because my own PR body advertised it as a feature: matching on variant sets is how a candidate gets found, not how it gets decided. The module has to own the wire format we parse. The other five hold up under that test — MemberAddMode from the group metadata schema, MemberShareGroupHistoryMode from the history-share module, PrivacyUserAction from the privacy job, GROUP_PARTICIPANT_TYPES from the group API, and DecryptFailType from the same WAWebBackendJobs.flow that already owns EncMediaType and the <enc> node — each is used for exactly what its module governs. Only this one crossed a domain boundary.

That rule is now in the emitter's module doc rather than only here, since a comment on a rejected candidate is not where the next sweep will look.


Generated by Claude Code

shape: Shape::Closed,
renames: &[],
default: None,
doc: "The media a call link carries.",
},
];

pub fn generate(ir: &EnumsIr) -> Result<String> {
Expand Down Expand Up @@ -201,9 +267,23 @@ fn wire_enum(wanted: &Wanted, def: &EnumDef) -> Result<String> {
wanted.module,
wanted.name
);
if wanted.default == Some(wire.as_str()) {
out.push_str(" #[wire_default]\n");
}
out.push_str(&format!(" #[wire = {}]\n {ident},\n", rust_str(wire)));
}

if let Some(default) = wanted.default {
ensure!(
def.variants
.iter()
.any(|v| matches!(&v.value, Scalar::Str(s) if s == default)),
"{}::{} declares {default:?} as its default, which the catalog does not carry",
wanted.module,
wanted.name
);
}

if wanted.shape == Shape::Open {
out.push_str(
" /// A value this build does not model, kept verbatim.\n #[wire_fallback]\n Unknown(String),\n",
Expand Down
74 changes: 57 additions & 17 deletions wacore/src/iq/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,7 @@ pub enum MemberLinkMode {
AllMemberLink,
}

/// Member add mode for who can add participants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MemberAddMode {
#[wire = "admin_add"]
AdminAdd,
#[wire = "all_member_add"]
AllMemberAdd,
}
pub use crate::types::wire_enums::MemberAddMode;

/// Membership approval mode for join requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
Expand All @@ -71,15 +64,7 @@ pub enum MembershipApprovalMode {
On,
}

/// Who can share message history with new members.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum MemberShareHistoryMode {
#[wire_default]
#[wire = "admin_share"]
AdminShare,
#[wire = "all_member_share"]
AllMemberShare,
}
pub use crate::types::wire_enums::MemberShareHistoryMode;

/// Review state for an appeal on a suspended group.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
Expand Down Expand Up @@ -3690,6 +3675,61 @@ mod tests {
use super::*;
use crate::request::InfoQueryType;

/// Whether a `w:g2` request is addressed to the group server or to one
/// group's own JID, checked against what the whatspec IR resolves for each
/// upstream builder.
///
/// The IR only started answering this in schema 4: before it, every `w:g2`
/// stanza reported the namespace's base target of `s.whatsapp.net`, so a
/// request sent to the wrong one of the two looked exactly like a request
/// sent to the right one. It now splits them (26 `group_jid`, 6 `g.us`),
/// and a mistake here is otherwise silent -- the server ignores the IQ and
/// the caller waits out its timeout.
///
/// This is a regression lock, not a derivation: the expectations below were
/// read off the IR by hand, since the IR is not available at test time.
#[test]
fn group_requests_are_addressed_the_way_the_ir_resolves_them() {
let group: Jid = "120363000000000001@g.us".parse().unwrap();
let server = Jid::new("", Server::Group);

// `g.us`: the group server answers these, not any one group.
assert_eq!(LeaveGroupIq::new(&group).build_iq().to, server);
assert_eq!(GroupParticipatingIq::new().build_iq().to, server);
assert_eq!(
BatchGetGroupInfoIq::new(std::slice::from_ref(&group))
.build_iq()
.to,
server
);
assert_eq!(GetGroupInviteInfoIq::new("ABC123").build_iq().to, server);

// `group_jid`: addressed to the one group they act on.
assert_eq!(
SetGroupSubjectIq::new(&group, GroupSubject::new("x").unwrap())
.build_iq()
.to,
group
);
assert_eq!(
GetGroupInviteLinkIq::new(&group, false).build_iq().to,
group
);
assert_eq!(
GetGroupInviteLinkIq::new(&group, true).build_iq().to,
group,
"the reset overload the IR resolves to group_jid is the one with an \
empty <invite/>; the g.us overload carries a code and is not this"
);
assert_eq!(
ReportGroupMessagesIq::new(&group, &["M1".to_string()])
.build_iq()
.to,
group
);
assert_eq!(GetReportedGroupMessagesIq::new(&group).build_iq().to, group);
}

#[test]
fn report_messages_iq_matches_the_group_report_shape() {
let jid: Jid = "120363000000000001@g.us".parse().unwrap();
Expand Down
9 changes: 1 addition & 8 deletions wacore/src/iq/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,7 @@ impl IqSpec for PrivacySettingsSpec {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum DisallowedListAction {
#[wire_default]
#[wire = "add"]
Add,
#[wire = "remove"]
Remove,
}
pub use crate::types::wire_enums::DisallowedListAction;

#[derive(Debug, Clone)]
pub struct DisallowedListUserEntry {
Expand Down
13 changes: 1 addition & 12 deletions wacore/src/stanza/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,7 @@ pub struct GroupNotification {
pub actions: Vec<GroupNotificationAction>,
}

/// Admin tier from `<participant type="...">`. Mirrors
/// `GROUP_PARTICIPANT_TYPES` in `WAWebGroupApiConst`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
pub enum GroupParticipantType {
#[wire_default]
#[wire = "participant"]
Participant,
#[wire = "admin"]
Admin,
#[wire = "superadmin"]
SuperAdmin,
}
pub use crate::types::wire_enums::GroupParticipantType;

/// Delivery state for history shared with a newly joined participant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)]
Expand Down
26 changes: 6 additions & 20 deletions wacore/src/types/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,11 @@ pub enum EventKind {
IncomingCall,
MissedCall,
CallEndedElsewhere,
PushNameUpdate,
/// Retired: the payload promised an old-name/new-name comparison this
/// client has no contact store to make, and nothing ever dispatched it.
/// The slot stays because the discriminant is an `EventInterest` bit index
/// a consumer persists, so removing it would re-point every mask past it.
RetiredPushNameUpdate,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the frozen PushNameUpdate API

Renaming this kind and removing Event::PushNameUpdate and its payload breaks consumers that subscribe to or match the existing public event; the in-tree chat store already references EventKind::PushNameUpdate and Event::PushNameUpdate at storages/chat-store/src/store.rs:130 and :981, so the workspace no longer compiles when that member is checked. Retire the behavior by ceasing dispatch while retaining the deprecated kind, variant, and payload under their original names.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The compile break was real and mine: I checked wacore and src and never storages/. Fixed in 561cea0.

On retaining the names, I'm keeping the removal, deliberately. The crate is pre-1.0 and the repo owner has confirmed breaking changes are acceptable there when the PR carries a migration line, which the body now does.

The reason a deprecated shim is worse than removal here: PushNameUpdate cannot be implemented. Its payload promises old_push_name alongside new_push_name, and there is no contact store in this repository to hold a previous name — every push_name in the tree is our own device's. Keeping the type means keeping a #[deprecated] struct that no code path can ever construct, which reads as "this fires and we'd rather you moved on" when the truth is "this has never fired and never can". A consumer matching that arm today gets silence; after this change they get a compile error pointing at MessageInfo::push_name, which is the value they were actually waiting for. The compile error is the useful outcome.

The frozen-payload contract in AGENTS.md and the Event doc governs how a live payload evolves — sealed with #[non_exhaustive], built through bon, absent fields as Option<T> rather than sentinels — so that adding a field is not a breaking change. It is not a promise that a variant which never dispatched can never be withdrawn.

What is genuinely frozen is the EventKind discriminant, because it doubles as an EventInterest bit index consumers persist. That is why the slot stays as RetiredPushNameUpdate rather than being deleted; persisted masks are unaffected.


Generated by Claude Code

SelfPushNameUpdated,
PinUpdate,
MuteUpdate,
Expand Down Expand Up @@ -921,7 +925,6 @@ pub enum Event {
/// Rejected call-log outcomes (`<terminate reason="accepted_elsewhere"|"rejected_elsewhere">`).
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
CallEndedElsewhere(CallEndedElsewhere),

PushNameUpdate(PushNameUpdate),
SelfPushNameUpdated(SelfPushNameUpdated),
Comment thread
jlucaso1 marked this conversation as resolved.
PinUpdate(PinUpdate),
MuteUpdate(MuteUpdate),
Expand Down Expand Up @@ -1119,7 +1122,6 @@ impl Event {
Event::IncomingCall(_) => EventKind::IncomingCall,
Event::MissedCall(_) => EventKind::MissedCall,
Event::CallEndedElsewhere(_) => EventKind::CallEndedElsewhere,
Event::PushNameUpdate(_) => EventKind::PushNameUpdate,
Event::SelfPushNameUpdated(_) => EventKind::SelfPushNameUpdated,
Event::AppStateSyncFailed(_) => EventKind::AppStateSyncFailed,
Event::PinUpdate(_) => EventKind::PinUpdate,
Expand Down Expand Up @@ -1668,13 +1670,7 @@ pub struct DirtyState {
pub timestamp: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)]
pub enum DecryptFailMode {
#[wire = "show"]
Show,
#[wire = "hide"]
Hide,
}
pub use crate::types::wire_enums::DecryptFailMode;

#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)]
pub enum UnavailableType {
Expand Down Expand Up @@ -2207,16 +2203,6 @@ pub struct ContactUpdate {
pub from_full_sync: bool,
}

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PushNameUpdate {
/// The contact who changed their push name.
pub jid: Jid,
pub message: Box<MessageInfo>,
pub old_push_name: String,
pub new_push_name: String,
}

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PinUpdate {
Expand Down
9 changes: 1 addition & 8 deletions wacore/src/types/group_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,7 @@ pub const GROUP_CALL_MAX_PARTICIPANTS: usize = 32;
/// The local endpoint consumes one membership slot.
pub const GROUP_CALL_MAX_REMOTE_PARTICIPANTS: usize = GROUP_CALL_MAX_PARTICIPANTS - 1;

/// Audio/video mode of a reusable call link.
#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)]
pub enum CallLinkMedia {
#[wire = "audio"]
Audio,
#[wire = "video"]
Video,
}
pub use crate::types::wire_enums::CallLinkMedia;

/// One device in an authoritative group-call roster.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, bon::Builder)]
Expand Down
9 changes: 0 additions & 9 deletions wacore/src/types/user.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
use chrono::{DateTime, Utc};
use waproto::whatsapp as wa;

#[derive(Debug, Clone)]
pub struct VerifiedName {
pub certificate: Box<wa::VerifiedNameCertificate>,
pub details: Box<wa::verified_name_certificate::Details>,
}

#[derive(Debug, Clone, Default)]
pub struct LocalChatSettings {
pub found: bool,
pub muted_until: Option<DateTime<Utc>>,
pub pinned: bool,
pub archived: bool,
}
Loading
Loading