diff --git a/Dawa.sln b/Dawa.sln
index 63d18dc..09833fb 100644
--- a/Dawa.sln
+++ b/Dawa.sln
@@ -1,32 +1,82 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa", "src\Dawa\Dawa.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Console", "samples\Dawa.Console\Dawa.Console.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Demo", "samples\Dawa.Demo\Dawa.Demo.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject
-
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawa.Tests", "tests\Dawa.Tests\Dawa.Tests.csproj", "{E6966028-2FC7-4734-9FDA-77BD0F825B7F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
- {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
- {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x64.Build.0 = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Debug|x86.Build.0 = Debug|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x64.ActiveCfg = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x64.Build.0 = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x86.ActiveCfg = Release|Any CPU
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {E6966028-2FC7-4734-9FDA-77BD0F825B7F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
EndGlobal
diff --git a/src/Dawa/Auth/AuthState.cs b/src/Dawa/Auth/AuthState.cs
index 309db40..016db57 100644
--- a/src/Dawa/Auth/AuthState.cs
+++ b/src/Dawa/Auth/AuthState.cs
@@ -53,6 +53,13 @@ public sealed class AuthState
[JsonPropertyName("lastAccountSyncTimestamp")]
public long LastAccountSyncTimestamp { get; set; }
+ ///
+ /// ADVSignedDeviceIdentity protobuf bytes (set during QR pairing).
+ /// Used for device-identity node when sending pkmsg.
+ ///
+ [JsonPropertyName("account")]
+ public byte[]? Account { get; set; }
+
/// True if this is a fresh (unauthenticated) state.
[JsonIgnore]
public bool IsFresh => Me == null;
@@ -96,9 +103,13 @@ public static AuthState CreateNew()
private static byte[] SignPreKey(byte[] identityPriv, byte[] preKeyPub)
{
// XEdDSA: uses the Curve25519 identity private key directly as an Ed25519 scalar.
- // This matches Baileys / Signal's curve25519-js curve25519_sign() behaviour.
+ // WhatsApp requires signing the 33-byte KEY_BUNDLE_TYPE form: [0x05 || pubKey].
+ // Baileys signedPreKey: curve.sign(identityKey.private, Buffer.concat([Buffer.from([5]), signedPreKey.public]))
// Returns a 64-byte signature (R ‖ s).
- return XEdDSA.Sign(identityPriv, preKeyPub);
+ var message = new byte[33];
+ message[0] = 0x05; // KEY_BUNDLE_TYPE
+ preKeyPub.CopyTo(message, 1);
+ return XEdDSA.Sign(identityPriv, message);
}
}
diff --git a/src/Dawa/Auth/SessionStore.cs b/src/Dawa/Auth/SessionStore.cs
index 1193bb2..41ac5e1 100644
--- a/src/Dawa/Auth/SessionStore.cs
+++ b/src/Dawa/Auth/SessionStore.cs
@@ -32,7 +32,12 @@ public async Task LoadAsync(CancellationToken ct = default)
var state = JsonSerializer.Deserialize(json, _jsonOpts);
if (state != null) return state;
}
- return AuthState.CreateNew();
+ // No saved session — generate fresh keys and save immediately.
+ // This ensures reconnects during QR phase (before pairing) reuse the same keys
+ // that were encoded in the QR, so the phone can pair successfully.
+ var fresh = AuthState.CreateNew();
+ await SaveAsync(fresh, ct);
+ return fresh;
}
/// Saves the current auth state to disk.
diff --git a/src/Dawa/Binary/BinaryNodeDecoder.cs b/src/Dawa/Binary/BinaryNodeDecoder.cs
index da0bdea..a65c034 100644
--- a/src/Dawa/Binary/BinaryNodeDecoder.cs
+++ b/src/Dawa/Binary/BinaryNodeDecoder.cs
@@ -87,10 +87,22 @@ private static byte[] ReadBinary(ref BinaryReader reader)
return reader.ReadBytes(length);
}
+ // Sentinel returned when ReadString encounters StreamEnd (byte 0x02).
+ internal const string StreamEndSentinel = "\x02";
+
private static string ReadString(ref BinaryReader reader)
{
var b = reader.ReadByte();
+ // StreamEnd byte — signals server closed the stream.
+ if (b == WATags.StreamEnd)
+ return StreamEndSentinel;
+
+ // Single-byte tokens: byte value IS the index into SingleByteTokens (Baileys convention).
+ if (b >= 1 && b < WATags.DictionaryBase)
+ return WATags.GetSingleByteToken(b) ?? $"[TOKEN{b}]";
+
+ // Double-byte tokens: next byte is the index into the chosen dictionary.
if (b >= WATags.DictionaryBase && b <= WATags.DictionaryBase + 3)
{
int dictIndex = b - WATags.DictionaryBase;
@@ -123,6 +135,25 @@ private static string ReadString(ref BinaryReader reader)
var server = ReadString(ref reader);
return $"{user}@{server}";
}
+ case WATags.AdJid:
+ {
+ // AD_JID (0xF7): domainType byte + device byte + user string
+ // Baileys: readAdJid() in decode.js
+ var domainType = reader.ReadByte();
+ var device = reader.ReadByte();
+ var user = ReadString(ref reader);
+ var server = (domainType == 0 || domainType == 128) ? "s.whatsapp.net" : "lid";
+ return device == 0 ? $"{user}@{server}" : $"{user}:{device}@{server}";
+ }
+ case WATags.InteropJid:
+ case WATags.FbJid:
+ {
+ // Consume bytes to stay in sync, return placeholder
+ var domainType = reader.ReadByte();
+ var device = reader.ReadByte();
+ var user = ReadString(ref reader);
+ return $"{user}@fb";
+ }
case WATags.Nibble8:
{
var size = reader.ReadByte();
diff --git a/src/Dawa/Binary/BinaryNodeEncoder.cs b/src/Dawa/Binary/BinaryNodeEncoder.cs
index dc11a84..72d794e 100644
--- a/src/Dawa/Binary/BinaryNodeEncoder.cs
+++ b/src/Dawa/Binary/BinaryNodeEncoder.cs
@@ -95,20 +95,42 @@ private static void WriteString(Stream s, string value)
return;
}
- // Try dictionary lookup first
- if (WATags.TryGetToken(value, out var dictByte, out var idxByte))
+ // Try single-byte token first (1 byte)
+ if (WATags.TryGetSingleByteToken(value, out var singleByte))
+ {
+ s.WriteByte(singleByte);
+ return;
+ }
+
+ // Try double-byte token (2 bytes: DictionaryBase+dict, index)
+ if (WATags.TryGetDoubleByteToken(value, out var dictByte, out var idxByte))
{
s.WriteByte(dictByte);
s.WriteByte(idxByte);
return;
}
- // Check if it's a JID (user@server)
+ // Check if it's a JID (user@server or @server with empty user)
var atIdx = value.IndexOf('@');
- if (atIdx > 0)
+ if (atIdx >= 0)
{
var user = value[..atIdx];
var server = value[(atIdx + 1)..];
+
+ // Check if this is an AD-JID (multi-device) with format "user:device@server"
+ var colonIdx = user.IndexOf(':');
+ if (colonIdx >= 0 && int.TryParse(user[(colonIdx + 1)..], out var deviceId))
+ {
+ var baseUser = user[..colonIdx];
+ // AD_JID format: tag(247) + domainType + device + writeString(user)
+ byte domainType = server == "lid" ? (byte)1 : (byte)0;
+ s.WriteByte(WATags.AdJid);
+ s.WriteByte(domainType);
+ s.WriteByte((byte)deviceId);
+ WriteString(s, baseUser);
+ return;
+ }
+
s.WriteByte(WATags.JidPair);
WriteString(s, user);
WriteString(s, server);
diff --git a/src/Dawa/Binary/WATags.cs b/src/Dawa/Binary/WATags.cs
index 0b2cc80..3ef1c99 100644
--- a/src/Dawa/Binary/WATags.cs
+++ b/src/Dawa/Binary/WATags.cs
@@ -2,14 +2,17 @@ namespace Dawa.Binary;
///
/// WhatsApp binary encoding constants and dictionaries.
-/// These tag bytes and string dictionaries are used to compress common strings.
+/// Sourced from @whiskeysockets/baileys SINGLE_BYTE_TOKENS and DOUBLE_BYTE_TOKENS.
///
public static class WATags
{
// Special byte tags
public const byte ListEmpty = 0;
public const byte StreamEnd = 2;
- public const byte DictionaryBase = 236; // 0xEC (DICTIONARY_0 through _3)
+ public const byte DictionaryBase = 236; // 0xEC (DICTIONARY_0 through _3 follow as second byte)
+ public const byte InteropJid = 245; // 0xF5
+ public const byte FbJid = 246; // 0xF6
+ public const byte AdJid = 247; // 0xF7 — multi-device JID: domainType + device + user
public const byte List8 = 248;
public const byte List16 = 249;
public const byte JidPair = 250;
@@ -19,138 +22,252 @@ public static class WATags
public const byte Binary32 = 254;
public const byte Nibble8 = 255;
- // Dictionaries — ordered lists of common WhatsApp strings.
- // Index in array = compressed token byte (after base offset).
+ // Single-byte tokens: index = byte value (1–235).
+ // Matches Baileys SINGLE_BYTE_TOKENS exactly.
+ public static readonly string?[] SingleByteTokens =
+ [
+ null, "xmlstreamstart", "xmlstreamend", "s.whatsapp.net", "type", "participant", "from", "receipt",
+ "id", "notification", "disappearing_mode", "status", "jid", "broadcast", "user", "devices",
+ "device_hash", "to", "offline", "message", "result", "class", "xmlns", "duration",
+ "notify", "iq", "t", "ack", "g.us", "enc", "urn:xmpp:whatsapp:push", "presence",
+ "config_value", "picture", "verified_name", "config_code", "key-index-list", "contact", "mediatype", "routing_info",
+ "edge_routing", "get", "read", "urn:xmpp:ping", "fallback_hostname", "0", "chatstate", "business_hours_config",
+ "unavailable", "download_buckets", "skmsg", "verified_level", "composing", "handshake", "device-list", "media",
+ "text", "fallback_ip4", "media_conn", "device", "creation", "location", "config", "item",
+ "fallback_ip6", "count", "w:profile:picture", "image", "business", "2", "hostname", "call-creator",
+ "display_name", "relaylatency", "platform", "abprops", "success", "msg", "offline_preview", "prop",
+ "key-index", "v", "day_of_week", "pkmsg", "version", "1", "ping", "w:p",
+ "download", "video", "set", "specific_hours", "props", "primary", "unknown", "hash",
+ "commerce_experience", "last", "subscribe", "max_buckets", "call", "profile", "member_since_text", "close_time",
+ "call-id", "sticker", "mode", "participants", "value", "query", "profile_options", "open_time",
+ "code", "list", "host", "ts", "contacts", "upload", "lid", "preview",
+ "update", "usync", "w:stats", "delivery", "auth_ttl", "context", "fail", "cart_enabled",
+ "appdata", "category", "atn", "direct_connection", "decrypt-fail", "relay_id", "mmg-fallback.whatsapp.net", "target",
+ "available", "name", "last_id", "mmg.whatsapp.net", "categories", "401", "is_new", "index",
+ "tctoken", "ip4", "token_id", "latency", "recipient", "edit", "ip6", "add",
+ "thumbnail-document", "26", "paused", "true", "identity", "stream:error", "key", "sidelist",
+ "background", "audio", "3", "thumbnail-image", "biz-cover-photo", "cat", "gcm", "thumbnail-video",
+ "error", "auth", "deny", "serial", "in", "registration", "thumbnail-link", "remove",
+ "00", "gif", "thumbnail-gif", "tag", "capability", "multicast", "item-not-found", "description",
+ "business_hours", "config_expo_key", "md-app-state", "expiration", "fallback", "ttl", "300", "md-msg-hist",
+ "device_orientation", "out", "w:m", "open_24h", "side_list", "token", "inactive", "01",
+ "document", "te2", "played", "encrypt", "msgr", "hide", "direct_path", "12",
+ "state", "not-authorized", "url", "terminate", "signature", "status-revoke-delay", "02", "te",
+ "linked_accounts", "trusted_contact", "timezone", "ptt", "kyc-id", "privacy_token", "readreceipts", "appointment_only",
+ "address", "expected_ts", "privacy", "7", "android", "interactive", "device-identity", "enabled",
+ "attribute_padding", "1080", "03", "screen_height",
+ ];
+
+ // Double-byte tokens: AllDictionaries[d][i] where byte = DictionaryBase+d, then next byte = i.
+ // Matches Baileys DOUBLE_BYTE_TOKENS[0..3].
+ // NOTE: AllDictionaries is declared AFTER Dictionary0..3 to ensure correct static init order.
+
public static readonly string[] Dictionary0 =
[
- "xmlstreamstart", "account", "action", "add", "after", "age", "all", "allow",
- "apple", "audio", "auth", "author", "available", "bad-protocol", "bad-request",
- "before", "biz", "body", "broadcast", "cancel", "category", "challenge", "chat",
- "clean", "code", "composing", "config", "conflict", "contacts", "count", "create",
- "creation", "default", "delay", "delete", "delivery", "denied", "device", "devices",
- "digest", "dirty", "disable", "duplicate", "elapsed", "enable", "encoding", "error",
- "expiration", "expired", "failure", "false", "favorites", "feature", "features",
- "field", "filter", "format", "from", "full", "get", "group", "groups", "groups_v2",
- "hash", "id", "image", "in", "index", "info", "interactive", "iq", "item",
- "item-not-found", "items", "jid", "kind", "last", "latitude", "lc", "leave",
- "level", "list", "live", "lg", "longitude", "media", "message", "method",
- "mime-type", "missing", "modify", "name", "not-authorized", "notification",
- "notify", "out", "owner", "participant", "paused", "picture", "ping", "platform",
- "presence", "preview", "proceed", "prop", "properties", "protocol", "public",
- "push", "query", "raw", "read", "receipt", "received", "recipient", "recording",
- "relay", "remove", "request", "response", "result", "retry", "s.whatsapp.net",
- "seconds", "server", "server-error", "server_id", "set", "show", "sid",
- "signature", "size", "star", "state", "status", "stream:error", "stream:features",
- "subject", "success", "subscribe", "t", "text", "timeout", "to", "true", "type",
- "unarchive", "unavailable", "unsubscribe", "update", "uri", "user", "value",
- "vcard", "versions", "video", "w", "w:g2", "w:p", "w:p:r", "w:profile:picture",
- "xml", "xmlns", "xmlns:stream", "~"
+ "read-self", "active", "fbns", "protocol", "reaction", "screen_width", "heartbeat", "deviceid",
+ "2:47DEQpj8", "uploadfieldstat", "voip_settings", "retry", "priority", "longitude", "conflict", "false",
+ "ig_professional", "replaced", "preaccept", "cover_photo", "uncompressed", "encopt", "ppic", "04",
+ "passive", "status-revoke-drop", "keygen", "540", "offer", "rate", "opus", "latitude",
+ "w:gp2", "ver", "4", "business_profile", "medium", "sender", "prev_v_id", "email",
+ "website", "invited", "sign_credential", "05", "transport", "skey", "reason", "peer_abtest_bucket",
+ "America/Sao_Paulo", "appid", "refresh", "100", "06", "404", "101", "104",
+ "107", "102", "109", "103", "member_add_mode", "105", "transaction-id", "110",
+ "106", "outgoing", "108", "111", "tokens", "followers", "ig_handle", "self_pid",
+ "tue", "dec", "thu", "joinable", "peer_pid", "mon", "features", "wed",
+ "peer_device_presence", "pn", "delete", "07", "fri", "audio_duration", "admin", "connected",
+ "delta", "rcat", "disable", "collection", "08", "480", "sat", "phash",
+ "all", "invite", "accept", "critical_unblock_low", "group_update", "signed_credential", "blinded_credential", "eph_setting",
+ "net", "09", "background_location", "refresh_id", "Asia/Kolkata", "privacy_mode_ts", "account_sync", "voip_payload_type",
+ "service_areas", "acs_public_key", "v_id", "0a", "fallback_class", "relay", "actual_actors", "metadata",
+ "w:biz", "5", "connected-limit", "notice", "0b", "host_storage", "fb_page", "subject",
+ "privatestats", "invis", "groupadd", "010", "note.m4r", "uuid", "0c", "8000",
+ "sun", "372", "1020", "stage", "1200", "720", "canonical", "fb",
+ "011", "video_duration", "0d", "1140", "superadmin", "012", "Opening.m4r", "keystore_attestation",
+ "dleq_proof", "013", "timestamp", "ab_key", "w:sync:app:state", "0e", "vertical", "600",
+ "p_v_id", "6", "likes", "014", "500", "1260", "creator", "0f",
+ "rte", "destination", "group", "group_info", "syncd_anti_tampering_fatal_exception_enabled", "015", "dl_bw", "Asia/Jakarta",
+ "vp8/h.264", "online", "1320", "fb:multiway", "10", "timeout", "016", "nse_retry",
+ "urn:xmpp:whatsapp:dirty", "017", "a_v_id", "web_shops_chat_header_button_enabled", "nse_call", "inactive-upgrade", "none", "web",
+ "groups", "2250", "mms_hot_content_timespan_in_seconds", "contact_blacklist", "nse_read", "suspended_group_deletion_notification", "binary_version", "018",
+ "https://www.whatsapp.com/otp/copy/", "reg_push", "shops_hide_catalog_attachment_entrypoint", "server_sync", ".", "ephemeral_messages_allowed_values", "019", "mms_vcache_aggregation_enabled",
+ "iphone", "America/Argentina/Buenos_Aires", "01a", "mms_vcard_autodownload_size_kb", "nse_ver", "shops_header_dropdown_menu_item", "dhash", "catalog_status",
+ "communities_mvp_new_iqs_serverprop", "blocklist", "default", "11", "ephemeral_messages_enabled", "01b", "original_dimensions", "8",
+ "mms4_media_retry_notification_encryption_enabled", "mms4_server_error_receipt_encryption_enabled", "original_image_url", "sync", "multiway", "420", "companion_enc_static", "shops_profile_drawer_entrypoint",
+ "01c", "vcard_as_document_size_kb", "status_video_max_duration", "request_image_url", "01d", "regular_high", "s_t", "abt",
+ "share_ext_min_preliminary_image_quality", "01e", "32", "syncd_key_rotation_enabled", "data_namespace", "md_downgrade_read_receipts2", "patch", "polltype",
+ "ephemeral_messages_setting", "userrate", "15", "partial_pjpeg_bw_threshold", "played-self", "catalog_exists", "01f", "mute_v2",
];
public static readonly string[] Dictionary1 =
[
- "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14",
- "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27",
- "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40",
- "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53",
- "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66",
- "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79",
- "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92",
- "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104",
- "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115",
- "116", "117", "118", "119", "120", "121", "122", "123", "124", "125", "126",
- "127", "128", "129", "130", "131", "132", "133", "134", "135", "136", "137",
- "138", "139", "140", "141", "142", "143", "144", "145", "146", "147", "148",
- "149", "150", "151", "152", "153", "154", "155", "156", "157", "158", "159",
- "160", "161", "162", "163", "164", "165", "166", "167", "168", "169", "170",
- "171", "172", "173", "174", "175", "176", "177", "178", "179", "180", "181",
- "182", "183", "184", "185", "186", "187", "188", "189", "190", "191", "192",
- "193", "194", "195", "196", "197", "198", "199", "200", "201", "202", "203",
- "204", "205", "206", "207", "208", "209", "210", "211", "212", "213", "214",
- "215", "216", "217", "218", "219", "220", "221", "222", "223", "224", "225",
- "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236",
- "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247",
- "248", "249", "250", "251", "252", "253", "254", "255"
+ "reject", "dirty", "announcement", "020", "13", "9", "status_video_max_bitrate", "fb:thrift_iq",
+ "offline_batch", "022", "full", "ctwa_first_business_reply_logging", "h.264", "smax_id", "group_description_length", "https://www.whatsapp.com/otp/code",
+ "status_image_max_edge", "smb_upsell_business_profile_enabled", "021", "web_upgrade_to_md_modal", "14", "023", "s_o", "smaller_video_thumbs_status_enabled",
+ "media_max_autodownload", "960", "blocking_status", "peer_msg", "joinable_group_call_client_version", "group_call_video_maximization_enabled", "return_snapshot", "high",
+ "America/Mexico_City", "entry_point_block_logging_enabled", "pop", "024", "1050", "16", "1380", "one_tap_calling_in_group_chat_size",
+ "regular_low", "inline_joinable_education_enabled", "hq_image_max_edge", "locked", "America/Bogota", "smb_biztools_deeplink_enabled", "status_image_quality", "1088",
+ "025", "payments_upi_intent_transaction_limit", "voip", "w:g2", "027", "md_pin_chat_enabled", "026", "multi_scan_pjpeg_download_enabled",
+ "shops_product_grid", "transaction_id", "ctwa_context_enabled", "20", "fna", "hq_image_quality", "alt_jpeg_doc_detection_quality", "group_call_max_participants",
+ "pkey", "America/Belem", "image_max_kbytes", "web_cart_v1_1_order_message_changes_enabled", "ctwa_context_enterprise_enabled", "urn:xmpp:whatsapp:account", "840", "Asia/Kuala_Lumpur",
+ "max_participants", "video_remux_after_repair_enabled", "stella_addressbook_restriction_type", "660", "900", "780", "context_menu_ios13_enabled", "mute-state",
+ "ref", "payments_request_messages", "029", "frskmsg", "vcard_max_size_kb", "sample_buffer_gif_player_enabled", "match_last_seen", "510",
+ "4983", "video_max_bitrate", "028", "w:comms:chat", "17", "frequently_forwarded_max", "groups_privacy_blacklist", "Asia/Karachi",
+ "02a", "web_download_document_thumb_mms_enabled", "02b", "hist_sync", "biz_block_reasons_version", "1024", "18", "web_is_direct_connection_for_plm_transparent",
+ "view_once_write", "file_max_size", "paid_convo_id", "online_privacy_setting", "video_max_edge", "view_once_read", "enhanced_storage_management", "multi_scan_pjpeg_encoding_enabled",
+ "ctwa_context_forward_enabled", "video_transcode_downgrade_enable", "template_doc_mime_types", "hq_image_bw_threshold", "30", "body", "u_aud_limit_sil_restarts_ctrl", "other",
+ "participating", "w:biz:directory", "1110", "vp8", "4018", "meta", "doc_detection_image_max_edge", "image_quality",
+ "1170", "02c", "smb_upsell_chat_banner_enabled", "key_expiry_time_second", "pid", "stella_interop_enabled", "19", "linked_device_max_count",
+ "md_device_sync_enabled", "02d", "02e", "360", "enhanced_block_enabled", "ephemeral_icon_in_forwarding", "paid_convo_status", "gif_provider",
+ "project_name", "server-error", "canonical_url_validation_enabled", "wallpapers_v2", "syncd_clear_chat_delete_chat_enabled", "medianotify", "02f", "shops_required_tos_version",
+ "vote", "reset_skey_on_id_change", "030", "image_max_edge", "multicast_limit_global", "ul_bw", "21", "25",
+ "5000", "poll", "570", "22", "031", "1280", "WhatsApp", "032",
+ "bloks_shops_enabled", "50", "upload_host_switching_enabled", "web_ctwa_context_compose_enabled", "ptt_forwarded_features_enabled", "unblocked", "partial_pjpeg_enabled", "fbid:devices",
+ "height", "ephemeral_group_query_ts", "group_join_permissions", "order", "033", "alt_jpeg_status_quality", "migrate", "popular-bank",
+ "win_uwp_deprecation_killswitch_enabled", "web_download_status_thumb_mms_enabled", "blocking", "url_text", "035", "web_forwarding_limit_to_groups", "1600", "val",
+ "1000", "syncd_msg_date_enabled", "bank-ref-id", "max_subject", "payments_web_enabled", "web_upload_document_thumb_mms_enabled", "size", "request",
+ "ephemeral", "24", "receipt_agg", "ptt_remember_play_position", "sampling_weight", "enc_rekey", "mute_always", "037",
+ "034", "23", "036", "action", "click_to_chat_qr_enabled", "width", "disabled", "038",
+ "md_blocklist_v2", "played_self_enabled", "web_buttons_message_enabled", "flow_id", "clear", "450", "fbid:thread", "bloks_session_state",
+ "America/Lima", "attachment_picker_refresh", "download_host_switching_enabled", "1792", "u_aud_limit_sil_restarts_test2", "custom_urls", "device_fanout", "optimistic_upload",
+ "2000", "key_cipher_suite", "web_smb_upsell_in_biz_profile_enabled", "e", "039", "siri_post_status_shortcut", "pair-device", "lg",
+ "lc", "stream_attribution_url", "model", "mspjpeg_phash_gen", "catalog_send_all", "new_multi_vcards_ui", "share_biz_vcard_enabled", "-",
+ "clean", "200", "md_blocklist_v2_server", "03b", "03a", "web_md_migration_experience", "ptt_conversation_waveform", "u_aud_limit_sil_restarts_test1",
];
public static readonly string[] Dictionary2 =
[
- "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
- "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "A", "B", "C", "D",
- "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S",
- "T", "U", "V", "W", "X", "Y", "Z", "200", "201", "202", "203", "204", "205",
- "206", "207", "208", "209", "210", "211", "212", "213", "214", "215", "216",
- "217", "218", "219", "220", "221", "222", "223", "224", "225", "226", "227",
- "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238",
- "239", "240", "241", "242", "243", "244", "245", "246", "247", "248", "249",
- "250", "251", "252", "253", "254", "255", "ac", "ae", "ag", "ah", "ai", "aj",
- "am", "an", "ao", "ap", "aq", "ar", "as", "at", "au", "av", "aw", "ax", "ay",
- "az", "ba", "bb", "bc", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bk", "bl",
- "bm", "bn", "bo", "bp", "bq", "br", "bs", "bt", "bu", "bv", "bw", "bx", "by",
- "bz", "ca", "cb", "cc", "cd", "ce", "cf", "cg", "ch", "ci", "cj", "ck", "cl",
- "cm", "cn", "co", "cp", "cq", "cr", "cs", "ct", "cu", "cv", "cw", "cx", "cy",
- "cz", "da", "db", "dc", "dd", "de", "df", "dg", "dh", "di", "dj", "dk", "dl",
- "dm", "dn", "do", "dp", "dq", "dr", "ds", "dt", "du", "dv", "dw", "dx", "dy",
- "dz", "ea", "eb", "ec", "ed", "ee", "ef", "eg", "eh", "ei", "ej", "ek", "el",
- "em", "en", "eo", "ep", "eq", "er", "es", "et", "eu", "ev", "ew", "ex", "ey",
- "ez", "fa", "fb", "fc", "fd", "fe", "ff", "fg", "fh", "fi", "fj", "fk", "fl",
- "fm", "fn", "fo", "fp", "fq", "fr", "fs", "ft", "fu", "fv", "fw", "fx", "fy",
- "fz"
+ "64", "ptt_playback_speed_enabled", "web_product_list_message_enabled", "paid_convo_ts", "27", "manufacturer", "psp-routing", "grp_uii_cleanup",
+ "ptt_draft_enabled", "03c", "business_initiated", "web_catalog_products_onoff", "web_upload_link_thumb_mms_enabled", "03e", "mediaretry", "35",
+ "hfm_string_changes", "28", "America/Fortaleza", "max_keys", "md_mhfs_days", "streaming_upload_chunk_size", "5541", "040",
+ "03d", "2675", "03f", "...", "512", "mute", "48", "041",
+ "alt_jpeg_quality", "60", "042", "md_smb_quick_reply", "5183", "c", "1343", "40",
+ "1230", "043", "044", "mms_cat_v1_forward_hot_override_enabled", "user_notice", "ptt_waveform_send", "047", "Asia/Calcutta",
+ "250", "md_privacy_v2", "31", "29", "128", "md_messaging_enabled", "046", "crypto",
+ "690", "045", "enc_iv", "75", "failure", "ptt_oot_playback", "AIzaSyDR5yfaG7OG8sMTUj8kfQEb8T9pN8BM6Lk", "w",
+ "048", "2201", "web_large_files_ui", "Asia/Makassar", "812", "status_collapse_muted", "1334", "257",
+ "2HP4dm", "049", "patches", "1290", "43cY6T", "America/Caracas", "web_sticker_maker", "campaign",
+ "ptt_pausable_enabled", "33", "42", "attestation", "biz", "04b", "query_linked", "s",
+ "125", "04a", "810", "availability", "1411", "responsiveness_v2_m1", "catalog_not_created", "34",
+ "America/Santiago", "1465", "enc_p", "04d", "status_info", "04f", "key_version", "..",
+ "04c", "04e", "md_group_notification", "1598", "1215", "web_cart_enabled", "37", "630",
+ "1920", "2394", "-1", "vcard", "38", "elapsed", "36", "828",
+ "peer", "pricing_category", "1245", "invalid", "stella_ios_enabled", "2687", "45", "1528",
+ "39", "u_is_redial_audio_1104_ctrl", "1025", "1455", "58", "2524", "2603", "054",
+ "bsp_system_message_enabled", "web_pip_redesign", "051", "verify_apps", "1974", "1272", "1322", "1755",
+ "052", "70", "050", "1063", "1135", "1361", "80", "1096",
+ "1828", "1851", "1251", "1921", "key_config_id", "1254", "1566", "1252",
+ "2525", "critical_block", "1669", "max_available", "w:auth:backup:token", "product", "2530", "870",
+ "1022", "participant_uuid", "web_cart_on_off", "1255", "1432", "1867", "41", "1415",
+ "1440", "240", "1204", "1608", "1690", "1846", "1483", "1687",
+ "1749", "69", "url_number", "053", "1325", "1040", "365", "59",
+ "Asia/Riyadh", "1177", "test_recommended", "057", "1612", "43", "1061", "1518",
+ "1635", "055", "1034", "1375", "750", "1430", "event_code", "1682",
+ "503", "55", "865", "78", "1309", "1365", "44", "America/Guayaquil",
+ "535", "LIMITED", "1377", "1613", "1420", "1599", "1822", "05a",
+ "1681", "password", "1111", "1214", "1376", "1478", "47", "1082",
+ "4282", "Europe/Istanbul", "1307", "46", "058", "1124", "256", "rate-overlimit",
+ "retail", "u_a_socket_err_fix_succ_test", "1292", "1370", "1388", "520", "861", "psa",
+ "regular", "1181", "1766", "05b", "1183", "1213", "1304", "1537",
];
public static readonly string[] Dictionary3 =
[
- "aba", "aca", "aga", "aha", "aia", "aja", "aka", "ala", "ama", "ana", "aoa",
- "apa", "aqa", "ara", "asa", "ata", "aua", "ava", "awa", "axa", "aya", "aza",
- "bba", "bca", "bda", "bea", "bfa", "bga", "bha", "bia", "bja", "bka", "bla",
- "bma", "bna", "boa", "bpa", "bqa", "bra", "bsa", "bta", "bua", "bva", "bwa",
- "bxa", "bya", "bza", "cca", "cda", "cea", "cfa", "cga", "cha", "cia", "cja",
- "cka", "cla", "cma", "cna", "coa", "cpa", "cqa", "cra", "csa", "cta", "cua",
- "cva", "cwa", "cxa", "cya", "cza", "dda", "dea", "dfa", "dga", "dha", "dia",
- "dja", "dka", "dla", "dma", "dna", "doa", "dpa", "dqa", "dra", "dsa", "dta",
- "dua", "dva", "dwa", "dxa", "dya", "dza", "eea", "efa", "ega", "eha", "eia",
- "eja", "eka", "ela", "ema", "ena", "eoa", "epa", "eqa", "era", "esa", "eta",
- "eua", "eva", "ewa", "exa", "eya", "eza", "ffa", "fga", "fha", "fia", "fja",
- "fka", "fla", "fma", "fna", "foa", "fpa", "fqa", "fra", "fsa", "fta", "fua",
- "fva", "fwa", "fxa", "fya", "fza", "gga", "gha", "gia", "gja", "gka", "gla",
- "gma", "gna", "goa", "gpa", "gqa", "gra", "gsa", "gta", "gua", "gva", "gwa",
- "gxa", "gya", "gza", "hha", "hia", "hja", "hka", "hla", "hma", "hna", "hoa",
- "hpa", "hqa", "hra", "hsa", "hta", "hua", "hva", "hwa", "hxa", "hya", "hza",
- "iia", "ija", "ika", "ila", "ima", "ina", "ioa", "ipa", "iqa", "ira", "isa",
- "ita", "iua", "iva", "iwa", "ixa", "iya", "iza", "jja", "jka", "jla", "jma",
- "jna", "joa", "jpa", "jqa", "jra", "jsa", "jta", "jua", "jva", "jwa", "jxa",
- "jya", "jza", "kka", "kla", "kma", "kna", "koa", "kpa", "kqa", "kra", "ksa",
- "kta", "kua", "kva", "kwa", "kxa", "kya", "kza", "lla", "lma", "lna", "loa",
- "lpa", "lqa", "lra", "lsa", "lta", "lua", "lva", "lwa", "lxa", "lya", "lza"
+ "1724", "profile_picture", "1071", "1314", "1605", "407", "990", "1710",
+ "746", "pricing_model", "056", "059", "061", "1119", "6027", "65",
+ "877", "1607", "05d", "917", "seen", "1516", "49", "470",
+ "973", "1037", "1350", "1394", "1480", "1796", "keys", "794",
+ "1536", "1594", "2378", "1333", "1524", "1825", "116", "309",
+ "52", "808", "827", "909", "495", "1660", "361", "957",
+ "google", "1357", "1565", "1967", "996", "1775", "586", "736",
+ "1052", "1670", "bank", "177", "1416", "2194", "2222", "1454",
+ "1839", "1275", "53", "997", "1629", "6028", "smba", "1378",
+ "1410", "05c", "1849", "727", "create", "1559", "536", "1106",
+ "1310", "1944", "670", "1297", "1316", "1762", "en", "1148",
+ "1295", "1551", "1853", "1890", "1208", "1784", "7200", "05f",
+ "178", "1283", "1332", "381", "643", "1056", "1238", "2024",
+ "2387", "179", "981", "1547", "1705", "05e", "290", "903",
+ "1069", "1285", "2436", "062", "251", "560", "582", "719",
+ "56", "1700", "2321", "325", "448", "613", "777", "791",
+ "51", "488", "902", "Asia/Almaty", "is_hidden", "1398", "1527", "1893",
+ "1999", "2367", "2642", "237", "busy", "065", "067", "233",
+ "590", "993", "1511", "54", "723", "860", "363", "487",
+ "522", "605", "995", "1321", "1691", "1865", "2447", "2462",
+ "NON_TRANSACTIONAL", "433", "871", "432", "1004", "1207", "2032", "2050",
+ "2379", "2446", "279", "636", "703", "904", "248", "370",
+ "691", "700", "1068", "1655", "2334", "060", "063", "364",
+ "533", "534", "567", "1191", "1210", "1473", "1827", "069",
+ "701", "2531", "514", "prev_dhash", "064", "496", "790", "1046",
+ "1139", "1505", "1521", "1108", "207", "544", "637", "final",
+ "1173", "1293", "1694", "1939", "1951", "1993", "2353", "2515",
+ "504", "601", "857", "modify", "spam_request", "p_121_aa_1101_test4", "866", "1427",
+ "1502", "1638", "1744", "2153", "068", "382", "725", "1704",
+ "1864", "1990", "2003", "Asia/Dubai", "508", "531", "1387", "1474",
+ "1632", "2307", "2386", "819", "2014", "066", "387", "1468",
+ "1706", "2186", "2261", "471", "728", "1147", "1372", "1961",
];
+ // AllDictionaries declared after the individual dicts to ensure correct static field init order.
public static readonly string[][] AllDictionaries = [Dictionary0, Dictionary1, Dictionary2, Dictionary3];
- // Build reverse lookup for encoding
- private static readonly Dictionary _reverseMap = BuildReverseMap();
+ // ─── Reverse lookups for encoding ─────────────────────────────────────────
- private static Dictionary BuildReverseMap()
+ private static readonly Dictionary _singleByteReverseMap = BuildSingleByteReverseMap();
+ private static readonly Dictionary _doubleByteReverseMap = BuildDoubleByteReverseMap();
+
+ private static Dictionary BuildSingleByteReverseMap()
{
- var map = new Dictionary();
+ var map = new Dictionary(StringComparer.Ordinal);
+ for (int i = 1; i < SingleByteTokens.Length; i++)
+ if (SingleByteTokens[i] != null)
+ map.TryAdd(SingleByteTokens[i]!, (byte)i);
+ return map;
+ }
+
+ private static Dictionary BuildDoubleByteReverseMap()
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
for (int d = 0; d < AllDictionaries.Length; d++)
for (int i = 0; i < AllDictionaries[d].Length; i++)
- map.TryAdd(AllDictionaries[d][i], (d, i));
+ if (AllDictionaries[d][i] != null)
+ map.TryAdd(AllDictionaries[d][i], (d, i));
return map;
}
- public static bool TryGetToken(string value, out byte dictionaryByte, out byte indexByte)
+ // ─── Encoding helpers ─────────────────────────────────────────────────────
+
+ ///
+ /// Try to encode as a single-byte token (byte value 1–235).
+ /// Returns true and the byte to write.
+ ///
+ public static bool TryGetSingleByteToken(string value, out byte tokenByte)
+ => _singleByteReverseMap.TryGetValue(value, out tokenByte);
+
+ ///
+ /// Try to encode as a double-byte token (DictionaryBase + dictIndex, tokenIndex).
+ /// Returns true and the two bytes to write.
+ ///
+ public static bool TryGetDoubleByteToken(string value, out byte dictionaryByte, out byte indexByte)
{
- if (_reverseMap.TryGetValue(value, out var pos))
+ if (_doubleByteReverseMap.TryGetValue(value, out var pos))
{
dictionaryByte = (byte)(DictionaryBase + pos.dict);
indexByte = (byte)pos.idx;
return true;
}
- dictionaryByte = 0;
- indexByte = 0;
+ dictionaryByte = indexByte = 0;
return false;
}
+ // ─── Decoding helpers ─────────────────────────────────────────────────────
+
+ /// Decode a single-byte token. Returns null if is out of range.
+ public static string? GetSingleByteToken(int b)
+ {
+ if (b < 0 || b >= SingleByteTokens.Length) return null;
+ return SingleByteTokens[b];
+ }
+
+ /// Decode a double-byte token. Returns null if indices are out of range.
public static string? GetToken(int dictionaryIndex, int tokenIndex)
{
if (dictionaryIndex < 0 || dictionaryIndex >= AllDictionaries.Length) return null;
diff --git a/src/Dawa/Crypto/AesGcmHelper.cs b/src/Dawa/Crypto/AesGcmHelper.cs
index c9ddd8e..c00b39e 100644
--- a/src/Dawa/Crypto/AesGcmHelper.cs
+++ b/src/Dawa/Crypto/AesGcmHelper.cs
@@ -114,11 +114,15 @@ public static byte[] DecryptRaw(byte[] key, byte[] nonce, byte[] ciphertextAndTa
private static byte[] CounterToNonce(ulong counter)
{
- // 12-byte nonce: 4 zero bytes + 8-byte big-endian counter
+ // Noise spec: 12-byte nonce, counter as 4-byte big-endian uint32 at offset 8.
+ // Baileys noise.ts: nonce.writeUInt32BE(counter, 8)
+ // Bytes 0-7 = zero, bytes 8-11 = counter (big-endian uint32)
var nonce = new byte[NonceSize];
- var counterBytes = BitConverter.GetBytes(counter);
- if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
- counterBytes.CopyTo(nonce, 4);
+ var c = (uint)counter;
+ nonce[8] = (byte)(c >> 24);
+ nonce[9] = (byte)(c >> 16);
+ nonce[10] = (byte)(c >> 8);
+ nonce[11] = (byte)(c);
return nonce;
}
}
diff --git a/src/Dawa/Crypto/MediaCrypto.cs b/src/Dawa/Crypto/MediaCrypto.cs
new file mode 100644
index 0000000..49a4b14
--- /dev/null
+++ b/src/Dawa/Crypto/MediaCrypto.cs
@@ -0,0 +1,96 @@
+using System.Security.Cryptography;
+using System.Text;
+using Dawa.Signal;
+
+namespace Dawa.Crypto;
+
+///
+/// WhatsApp media encryption — derives keys from a random media key using HKDF,
+/// then encrypts with AES-CBC and appends a 10-byte HMAC-SHA256 MAC.
+///
+public sealed class MediaEncryptResult
+{
+ public byte[] EncryptedBytes { get; init; } = []; // ciphertext || 10-byte mac
+ public byte[] MediaKey { get; init; } = []; // 32-byte random key (sent in proto)
+ public byte[] FileSha256 { get; init; } = []; // SHA-256 of original plaintext
+ public byte[] FileEncSha256 { get; init; } = []; // SHA-256 of EncryptedBytes
+ public long FileLength { get; init; } // original byte count
+ public string DirectPath { get; init; } = ""; // set after CDN upload
+ public string Url { get; init; } = ""; // set after CDN upload
+ public long MediaKeyTimestamp { get; init; }
+}
+
+public static class MediaCrypto
+{
+ private static string HkdfInfo(string mediaType) => mediaType switch
+ {
+ "image" => "WhatsApp Image Keys",
+ "video" => "WhatsApp Video Keys",
+ "audio" => "WhatsApp Audio Keys",
+ "document" => "WhatsApp Document Keys",
+ "md-msg-hist" => "WhatsApp History Keys",
+ _ => "WhatsApp Image Keys",
+ };
+
+ ///
+ /// Encrypts for WhatsApp media upload.
+ /// is one of: "image", "video", "audio", "document".
+ ///
+ public static MediaEncryptResult Encrypt(byte[] plaintext, string mediaType)
+ {
+ // 1. Random 32-byte media key
+ var mediaKey = RandomNumberGenerator.GetBytes(32);
+
+ // 2. HKDF-expand: 112 bytes (IV=16, AES key=32, MAC key=32, ref=32)
+ var info = Encoding.UTF8.GetBytes(HkdfInfo(mediaType));
+ var derived = DawaHKDF.DeriveKey(mediaKey, new byte[32], info, 112);
+ var iv = derived[0..16];
+ var cipherKey = derived[16..48];
+ var macKey = derived[48..80];
+
+ // 3. AES-CBC encrypt
+ var enc = MessageCipher.AesCbcEncrypt(cipherKey, iv, plaintext);
+
+ // 4. HMAC-SHA256(macKey, IV || enc) — first 10 bytes only
+ var macInput = new byte[iv.Length + enc.Length];
+ iv.CopyTo(macInput, 0);
+ enc.CopyTo(macInput, iv.Length);
+ var mac = HMACSHA256.HashData(macKey, macInput)[..10];
+
+ // 5. Final encrypted blob = enc || mac (Baileys format — NO IV).
+ // The IV is NOT uploaded to the CDN; receivers derive it from mediaKey via HKDF.
+ // The CDN stores only enc+mac and verifies SHA256(enc+mac) == token in URL.
+ var encFile = new byte[enc.Length + mac.Length];
+ enc.CopyTo(encFile, 0);
+ mac.CopyTo(encFile, enc.Length);
+
+ return new MediaEncryptResult
+ {
+ EncryptedBytes = encFile,
+ MediaKey = mediaKey,
+ FileSha256 = SHA256.HashData(plaintext),
+ FileEncSha256 = SHA256.HashData(encFile),
+ FileLength = plaintext.Length,
+ MediaKeyTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ };
+ }
+
+ ///
+ /// Decrypts a WhatsApp CDN blob (enc || 10-byte mac) back to plaintext.
+ /// Used for downloading media and history sync blobs.
+ /// e.g. "image", "md-msg-hist".
+ ///
+ public static byte[] Decrypt(byte[] encWithMac, byte[] mediaKey, string mediaType)
+ {
+ // Derive the same keys the sender used
+ var info = Encoding.UTF8.GetBytes(HkdfInfo(mediaType));
+ var derived = DawaHKDF.DeriveKey(mediaKey, new byte[32], info, 112);
+ var iv = derived[0..16];
+ var cipherKey = derived[16..48];
+
+ // Strip the 10-byte MAC at the end — we trust the server validated it
+ var enc = encWithMac[..^10];
+
+ return MessageCipher.AesCbcDecrypt(cipherKey, iv, enc);
+ }
+}
diff --git a/src/Dawa/Crypto/XEdDSA.cs b/src/Dawa/Crypto/XEdDSA.cs
index 4a33472..d71562d 100644
--- a/src/Dawa/Crypto/XEdDSA.cs
+++ b/src/Dawa/Crypto/XEdDSA.cs
@@ -42,6 +42,53 @@ static XEdDSA()
// ─── Public API ───────────────────────────────────────────────────────
+ ///
+ /// Verifies an XEdDSA signature.
+ /// is the 32-byte Curve25519 (Montgomery x) public key.
+ ///
+ public static bool Verify(byte[] curve25519PublicKey, byte[] message, byte[] signature)
+ {
+ if (signature.Length != 64) return false;
+
+ // 1. Convert Curve25519 Montgomery x → Edwards y: y = (u-1)/(u+1) mod p
+ var u = LoadLE(curve25519PublicKey);
+ var y = Fp((u - 1) * FpInv(u + 1));
+
+ // 2. Recover Edwards x from y (positive/even root)
+ var y2 = Fp(y * y);
+ var x2 = Fp((y2 - 1) * FpInv(Fp(D * y2 + 1)));
+ var ax = FpSqrt(x2);
+ if (!ax.IsEven) ax = P - ax;
+ var A = (ax, y);
+ var Ap = PackPoint(A);
+
+ // 3. Parse R (packed point) and s from signature
+ var Rp = signature[0..32];
+ var sBuf = signature[32..64];
+ var s = LoadLE(sBuf);
+ if (s >= L) return false;
+
+ // 4. Unpack R
+ var Rbuf = (byte[])Rp.Clone();
+ var Rsign = (Rbuf[31] & 0x80) != 0;
+ Rbuf[31] &= 0x7F;
+ var Ry = LoadLE(Rbuf);
+ var Ry2 = Fp(Ry * Ry);
+ var Rx2 = Fp((Ry2 - 1) * FpInv(Fp(D * Ry2 + 1)));
+ var Rx = FpSqrt(Rx2);
+ if (Rsign != !Rx.IsEven) Rx = P - Rx;
+ var R = (Rx, Ry);
+
+ // 5. h = SHA-512(R ‖ A ‖ message) mod l
+ var hHash = SHA512.HashData([.. Rp, .. Ap, .. message]);
+ var h = Fl(LoadLE64(hHash));
+
+ // 6. s·G == R + h·A
+ var lhs = PointMult(G, s);
+ var rhs = PointAdd(R, PointMult(A, h));
+ return lhs.X == rhs.X && lhs.Y == rhs.Y;
+ }
+
///
/// Signs using XEdDSA with the given 32-byte
/// Curve25519 private key. Returns a 64-byte signature (R ‖ s).
@@ -49,31 +96,41 @@ static XEdDSA()
public static byte[] Sign(byte[] privateKey, byte[] message)
{
// 1. Clamp Curve25519 private key → Ed25519 scalar a
- var a = (byte[])privateKey.Clone();
- a[0] &= 248; a[31] &= 127; a[31] |= 64;
- var aInt = LoadLE(a);
+ var aClamped = (byte[])privateKey.Clone();
+ aClamped[0] &= 248; aClamped[31] &= 127; aClamped[31] |= 64;
+ var aInt = LoadLE(aClamped);
// 2. A = a × G (Ed25519 public key derived from the scalar)
- var A = PointMult(G, aInt);
- var Ap = PackPoint(A);
+ var A = PointMult(G, aInt);
- // 3. r = SHA-512(A ‖ message) mod l
- var rHash = SHA512.HashData([.. Ap, .. message]);
+ // 3. XEdDSA sign convention: A must have a non-negative (even) x-coordinate.
+ // Verifiers recover A from the Curve25519 key using always the positive x root.
+ // If our A.x is negative (odd), negate the scalar so A becomes -A (same y, flipped x).
+ if (!A.X.IsEven)
+ {
+ aInt = Fl(L - aInt); // negate scalar mod l
+ A = (Fp(P - A.X), A.Y); // flip x sign (same point, opposite x sign)
+ }
+ var Ap = PackPoint(A); // bit 255 of Ap is now always 0
+
+ // 4. r = SHA-512(a ‖ message ‖ Z) mod l (Signal XEdDSA nonce, using final scalar)
+ var aBytes = ToLE32(aInt);
+ var Z = System.Security.Cryptography.RandomNumberGenerator.GetBytes(64);
+ var rHash = SHA512.HashData([.. aBytes, .. message, .. Z]);
var r = Fl(LoadLE64(rHash));
- var rBytes = ToLE32(r);
- // 4. R = r × G
+ // 5. R = r × G
var R = PointMult(G, r);
var Rp = PackPoint(R);
- // 5. h = SHA-512(R ‖ A ‖ message) mod l
+ // 6. h = SHA-512(R ‖ A ‖ message) mod l
var hHash = SHA512.HashData([.. Rp, .. Ap, .. message]);
var h = Fl(LoadLE64(hHash));
- // 6. s = (r + h·a) mod l
+ // 7. s = (r + h·a) mod l
var s = Fl(r + h * aInt);
- // 7. Signature = R (32 bytes) ‖ s (32 bytes)
+ // 8. Signature = R (32 bytes) ‖ s (32 bytes)
var sig = new byte[64];
Rp.CopyTo(sig, 0);
ToLE32(s).CopyTo(sig, 32);
diff --git a/src/Dawa/Dawa.csproj b/src/Dawa/Dawa.csproj
index 79ed12d..cb517c1 100644
--- a/src/Dawa/Dawa.csproj
+++ b/src/Dawa/Dawa.csproj
@@ -20,10 +20,6 @@
true
-
-
-
-
@@ -31,4 +27,9 @@
+
+
+
+
+
diff --git a/src/Dawa/Messages/IncomingMessage.cs b/src/Dawa/Messages/IncomingMessage.cs
index 5e161d7..599b7e7 100644
--- a/src/Dawa/Messages/IncomingMessage.cs
+++ b/src/Dawa/Messages/IncomingMessage.cs
@@ -1,5 +1,30 @@
namespace Dawa.Messages;
+/// Message type classification.
+public enum MessageType
+{
+ Unknown,
+ Text,
+ Image,
+ Video,
+ Audio,
+ Document,
+ Sticker,
+ Reaction,
+ Location,
+ Contact,
+ Protocol, // ProtocolMessage (history sync, key distribution, etc.)
+}
+
+/// Delivery/read status of an outgoing message.
+public enum MessageStatus
+{
+ Sent, // Sent to WhatsApp servers
+ Delivered, // Delivered to recipient device
+ Read, // Recipient read the message
+ Played, // Recipient played the voice note
+}
+
///
/// Represents a received WhatsApp message.
///
@@ -8,7 +33,7 @@ public sealed class IncomingMessage
/// Unique message ID assigned by WhatsApp.
public string Id { get; init; } = "";
- /// Sender JID (e.g. "31612345678@s.whatsapp.net").
+ /// Sender JID (e.g. "31612345678@s.whatsapp.net" or LID).
public string From { get; init; } = "";
/// The conversation/chat JID.
@@ -17,7 +42,10 @@ public sealed class IncomingMessage
/// For group messages: the participant who sent the message.
public string? Participant { get; init; }
- /// Text content of the message, if it's a text message.
+ /// Classified message type.
+ public MessageType Type { get; init; } = MessageType.Unknown;
+
+ /// Text content (for Text messages, or caption for media).
public string? Text { get; init; }
/// True if this message was sent by ourselves.
@@ -26,9 +54,67 @@ public sealed class IncomingMessage
/// Unix timestamp (seconds) when the message was sent.
public long Timestamp { get; init; }
- /// UTC timestamp of the message.
+ /// The push name (display name) of the sender, if provided.
+ public string? PushName { get; init; }
+
+ /// True if this message has been revoked (deleted for everyone).
+ public bool IsRevoked { get; init; }
+
+ // ── Media fields ──────────────────────────────────────────────────────────
+
+ /// Direct CDN URL to download the encrypted media.
+ public string? MediaUrl { get; init; }
+
+ /// MIME type (e.g. "image/jpeg", "audio/ogg; codecs=opus").
+ public string? MimeType { get; init; }
+
+ /// File name (primarily for documents).
+ public string? FileName { get; init; }
+
+ /// File size in bytes.
+ public long? FileSize { get; init; }
+
+ /// Duration in seconds (audio/video).
+ public uint? Duration { get; init; }
+
+ /// Width in pixels (image/video/sticker).
+ public uint? Width { get; init; }
+
+ /// Height in pixels (image/video/sticker).
+ public uint? Height { get; init; }
+
+ /// Base64-encoded AES key to decrypt the media download.
+ public string? MediaKey { get; init; }
+
+ /// Base64-encoded SHA256 of the encrypted media blob.
+ public string? MediaSha256Enc { get; init; }
+
+ // ── Reaction fields ───────────────────────────────────────────────────────
+
+ /// Reaction emoji (e.g. "👍"). Empty string = reaction removed.
+ public string? ReactionEmoji { get; init; }
+
+ /// ID of the message being reacted to.
+ public string? ReactionTargetId { get; init; }
+
+ // ── Quoted/reply context ──────────────────────────────────────────────────
+
+ /// ID of the message this is a reply to.
+ public string? QuotedMessageId { get; init; }
+
+ /// JID of the sender of the quoted message.
+ public string? QuotedFrom { get; init; }
+
+ /// Text preview of the quoted message.
+ public string? QuotedText { get; init; }
+
+ /// Type of the quoted message.
+ public MessageType QuotedType { get; init; } = MessageType.Unknown;
+
+ // ── UTC helper ────────────────────────────────────────────────────────────
+
public DateTimeOffset SentAt => DateTimeOffset.FromUnixTimeSeconds(Timestamp);
public override string ToString() =>
- $"[{SentAt:HH:mm:ss}] {(FromMe ? "ME" : From)}: {Text ?? ""}";
+ $"[{SentAt:HH:mm:ss}] {(FromMe ? "ME" : From)} [{Type}]: {Text ?? MediaUrl ?? ReactionEmoji ?? ""}";
}
diff --git a/src/Dawa/Noise/NoiseProcessor.cs b/src/Dawa/Noise/NoiseProcessor.cs
index 96e5f57..510ad6a 100644
--- a/src/Dawa/Noise/NoiseProcessor.cs
+++ b/src/Dawa/Noise/NoiseProcessor.cs
@@ -1,3 +1,4 @@
+using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -6,6 +7,7 @@
using Dawa.Crypto;
using Dawa.Messages;
using Dawa.Proto;
+using Dawa.Signal;
using Dawa.Transport;
using Microsoft.Extensions.Logging;
@@ -38,9 +40,39 @@ public sealed class NoiseProcessor : IAsyncDisposable
private readonly byte[] _ephemeralPriv;
private readonly byte[] _ephemeralPub;
+ // Signal Protocol session store
+ private readonly SignalKeyStore _signalStore;
+
+ // Pending IQ tracking for request/response correlation
+ private readonly Dictionary> _pendingIqs = new();
+
+ // Only fire Authenticated + upload pre-keys once per connection (not on every periodic success token)
+ private bool _sessionAuthenticated;
+ private CancellationTokenSource? _keepAliveCts;
+
+ // When WhatsApp sends edge_routing, we store the bytes for use on next reconnect.
+ // We do NOT break the receive loop — only a stream:error or socket close causes a reconnect.
+ public byte[]? PendingRoutingInfo { get; private set; }
+
+ // Shared HttpClient for CDN media uploads
+ private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(60) };
+
+ // Track the last on-demand history request so we can auto-resend when the phone sends a retry receipt
+ private (string ChatJid, string? OldestMsgId, bool OldestMsgFromMe, long OldestMsgTimestampMs, int Count)? _lastPdoRequest;
+ private readonly HashSet _sentPdoMsgIds = new();
+
+ // Index into auth.PreKeys for retry receipts — incremented per receipt so each message
+ // gets a unique one-time pre-key. Without this all retry receipts would advertise the
+ // same pre-key; only the first re-encrypted message would decrypt (the key gets consumed).
+ private int _retryPreKeyIndex = 0;
+
public event EventHandler? QRCodeGenerated;
public event EventHandler? Authenticated;
public event EventHandler? MessageReceived;
+ /// Fired for each message loaded from history sync (past messages).
+ public event EventHandler? HistoryMessageReceived;
+ /// Fired once after a history-sync blob has been fully processed (use to flush persisted store).
+ public event EventHandler? HistorySyncCompleted;
public NoiseProcessor(FrameSocket socket, AuthState auth, WhatsAppClientOptions options, ILogger logger)
{
@@ -50,6 +82,49 @@ public NoiseProcessor(FrameSocket socket, AuthState auth, WhatsAppClientOptions
_logger = logger;
(_ephemeralPriv, _ephemeralPub) = Curve25519Helper.GenerateKeyPair();
+ _signalStore = new SignalKeyStore(options.SessionDirectory);
+
+ // Load persisted LID/push-name caches from previous sessions
+ LoadCacheFromDisk();
+ }
+
+ private string CacheFilePath => Path.Combine(_options.SessionDirectory, "contact-cache.json");
+
+ private void LoadCacheFromDisk()
+ {
+ try
+ {
+ if (!File.Exists(CacheFilePath)) return;
+ var json = File.ReadAllText(CacheFilePath);
+ var data = System.Text.Json.JsonSerializer.Deserialize(json);
+ if (data == null) return;
+ foreach (var kv in data.LidToPhone) _lidToPhone[kv.Key] = kv.Value;
+ foreach (var kv in data.PushNames) _pushNames[kv.Key] = kv.Value;
+ _logger.LogInformation("Loaded contact cache: {Lids} LID mappings, {Names} push names",
+ _lidToPhone.Count, _pushNames.Count);
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to load contact cache"); }
+ }
+
+ private void SaveCacheToDisk()
+ {
+ try
+ {
+ var data = new ContactCacheFile
+ {
+ LidToPhone = new Dictionary(_lidToPhone),
+ PushNames = new Dictionary(_pushNames),
+ };
+ var json = System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = false });
+ File.WriteAllText(CacheFilePath, json);
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to save contact cache"); }
+ }
+
+ private sealed class ContactCacheFile
+ {
+ public Dictionary LidToPhone { get; set; } = new();
+ public Dictionary PushNames { get; set; } = new();
}
// ─── Handshake ───────────────────────────────────────────────────────────
@@ -114,6 +189,13 @@ public async Task PerformHandshakeAsync(CancellationToken ct)
var clientPayload = BuildClientPayload();
var encPayload = noise.EncryptWithAssociatedData(clientPayload);
+ _logger.LogInformation("Noise: ClientFinish encStaticPub({Len})={Hex}",
+ encStaticPub.Length, BitConverter.ToString(encStaticPub));
+ _logger.LogInformation("Noise: ClientFinish encPayload({Len})={Hex}",
+ encPayload.Length, BitConverter.ToString(encPayload));
+ _logger.LogInformation("Noise: ClientPayload raw({Len})={Hex}",
+ clientPayload.Length, BitConverter.ToString(clientPayload));
+
var clientFinish = new ClientFinish
{
Static = encStaticPub,
@@ -160,6 +242,12 @@ private Task HandlePostHandshakeAsync(CancellationToken ct)
///
/// Continuously reads and processes incoming frames. Call this on a background task.
///
+ // Timestamp (ticks) of the last frame received from the server. The keepalive loop
+ // watches this to detect a dead/half-open connection (no data for too long) and tear
+ // the socket down so the receive loop unblocks and reconnection can run. Without this a
+ // silently-dropped socket parks ReceiveAsync forever (root cause of the 85-min outage).
+ private long _lastRecvTicks = DateTime.UtcNow.Ticks;
+
public async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _socket.IsConnected)
@@ -169,10 +257,39 @@ public async Task ReceiveLoopAsync(CancellationToken ct)
var frame = await _socket.ReceiveFrameAsync(ct);
if (frame == null) break;
+ System.Threading.Interlocked.Exchange(ref _lastRecvTicks, DateTime.UtcNow.Ticks);
+
+ _logger.LogInformation("Noise: Received raw frame ({Len} bytes), first bytes={Hex}",
+ frame.Length, BitConverter.ToString(frame, 0, Math.Min(32, frame.Length)));
+
var decrypted = DecryptFrame(frame);
- var node = BinaryNodeDecoder.Decode(decrypted);
- _logger.LogDebug("Received node: {Tag}", node.Tag);
+ // Baileys protocol: first byte is a flags byte.
+ // Bit 1 (value 2) = payload is zlib raw-deflate compressed.
+ // Always strip this byte before decoding the binary node.
+ var nodeData = StripFlagsAndDecompress(decrypted);
+ _logger.LogInformation("Noise: Decrypted frame ({Len} bytes), first bytes={Hex}",
+ decrypted.Length, BitConverter.ToString(decrypted, 0, Math.Min(32, decrypted.Length)));
+
+ var node = BinaryNodeDecoder.Decode(nodeData);
+ // Server sent StreamEnd — graceful close, stop receive loop.
+ if (node.Tag == BinaryNodeDecoder.StreamEndSentinel)
+ {
+ _logger.LogInformation("Server sent stream-end, closing.");
+ break;
+ }
+
+ var nodeStr = node.ToString();
+ _logger.LogInformation("Received node: {Node}", nodeStr);
+ // Write to file log (bypasses IIS stdout 8KB cap)
+ try
+ {
+ var logDir = Path.Combine(_options.SessionDirectory, "..", "nodelog");
+ Directory.CreateDirectory(logDir);
+ var logFile = Path.Combine(logDir, "nodes.log");
+ File.AppendAllText(logFile, $"[{DateTime.UtcNow:HH:mm:ss}] {nodeStr}\n");
+ }
+ catch { /* non-fatal */ }
await HandleNodeAsync(node, ct);
}
catch (OperationCanceledException) { break; }
@@ -183,12 +300,18 @@ public async Task ReceiveLoopAsync(CancellationToken ct)
}
}
_logger.LogInformation("Receive loop ended.");
+ SaveCacheToDisk();
}
private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct)
{
switch (node.Tag)
{
+ case "chat" when node.GetAttr("add") == "@xmlstreamstart":
+ // Multi-device: server embeds pair-device refs directly in the xmlstreamstart frame.
+ // Structure: [[102 bytes]]×6
+ await HandleXmlStreamStartAsync(node, ct);
+ break;
case "iq":
await HandleIQAsync(node, ct);
break;
@@ -198,9 +321,39 @@ private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct)
case "notification":
await HandleNotificationAsync(node, ct);
break;
+ case "presence":
+ HandlePresenceNode(node);
+ break;
+ case "receipt":
+ await HandleReceiptAsync(node, ct);
+ break;
case "success":
- _logger.LogInformation("Session authenticated successfully.");
- Authenticated?.Invoke(this, _auth);
+ if (!_sessionAuthenticated)
+ {
+ _sessionAuthenticated = true;
+ _logger.LogInformation("Session authenticated successfully.");
+ Authenticated?.Invoke(this, _auth);
+ // Upload pre-keys so other devices can start Signal sessions with us,
+ // then announce presence so WhatsApp starts delivering messages to this device.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await UploadPreKeysAsync(ct);
+ await SendActiveAsync(ct); // CRITICAL: switch session from passive → active
+ await SendPresenceAsync(ct);
+ await SendAppStateSyncAsync(ct);
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Post-auth setup failed (non-fatal)"); }
+ });
+ // Start proactive keepalive pings (every 25s, like Baileys)
+ _keepAliveCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _ = Task.Run(() => KeepAliveLoopAsync(_keepAliveCts.Token));
+ }
+ else
+ {
+ _logger.LogDebug("Received periodic success token (session already authenticated, skipping re-init).");
+ }
break;
case "failure":
_logger.LogWarning("Authentication failure: {Reason}", node.GetAttr("reason"));
@@ -208,16 +361,199 @@ private async Task HandleNodeAsync(BinaryNode node, CancellationToken ct)
case "stream:error":
_logger.LogError("Stream error: {Code}", node.GetAttr("code"));
break;
+ case "ib":
+ await HandleIbAsync(node, ct);
+ break;
default:
_logger.LogDebug("Unhandled node tag: {Tag}", node.Tag);
break;
}
}
+ ///
+ /// Handles informational broadcast (ib) nodes from WhatsApp.
+ /// The most important one is dirty type="account_sync" — WhatsApp withholds
+ /// message delivery until the client sends a "clean" IQ to acknowledge the sync.
+ ///
+ private async Task HandleIbAsync(BinaryNode ib, CancellationToken ct)
+ {
+ foreach (var child in ib.Children)
+ {
+ if (child.Tag == "dirty")
+ {
+ var dirtyType = child.GetAttr("type") ?? "";
+ var timestamp = child.GetAttr("timestamp") ?? "";
+
+ _logger.LogInformation("Received dirty notification: type={Type}, timestamp={Ts} — sending clean IQ", dirtyType, timestamp);
+
+ var cleanIq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "set",
+ ["xmlns"] = "urn:xmpp:whatsapp:dirty",
+ ["id"] = GenerateMessageId(),
+ })
+ {
+ Content = new List
+ {
+ new("clean", new Dictionary
+ {
+ ["type"] = dirtyType,
+ ["timestamp"] = timestamp,
+ }),
+ },
+ };
+
+ try
+ {
+ await SendNodeAsync(cleanIq, ct);
+ _logger.LogInformation("Sent clean IQ for dirty type={Type} — message delivery should resume.", dirtyType);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send clean IQ for dirty type={Type}", dirtyType);
+ }
+ }
+ else if (child.Tag == "edge_routing")
+ {
+ // WhatsApp suggests a preferred edge server. Store routing bytes for use on next
+ // reconnect (triggered by stream:error or socket close). Do NOT force an immediate
+ // disconnect — that causes an infinite reconnect loop.
+ var routingInfo = child.FindChild("routing_info");
+ if (routingInfo?.Content is byte[] routingBytes && routingBytes.Length > 0)
+ {
+ PendingRoutingInfo = routingBytes;
+ _logger.LogInformation("Received edge_routing ({Len} bytes) — stored for next reconnect.", routingBytes.Length);
+ }
+ }
+ else if (child.Tag == "offline_preview")
+ {
+ // WA sends offline_preview to tell us there are queued offline messages.
+ // Baileys responds with to trigger delivery.
+ // Without this, WA never delivers the offline messages.
+ var msgCount = child.GetAttr("message") ?? child.GetAttr("count") ?? "0";
+ // Request the FULL pending count (not a fixed 100) so the entire offline queue
+ // drains in one reconnect instead of 100-at-a-time across many. A healthy client
+ // (Baileys) drains the whole queue; combined with ACK-and-skip of dead backlog
+ // this keeps the bridge current. Cap high as a sanity bound.
+ var batchCount = (int.TryParse(msgCount, out var mc) && mc > 0) ? Math.Min(mc, 100000) : 5000;
+ _logger.LogInformation("offline_preview: {Count} queued — requesting full offline_batch of {Batch}", msgCount, batchCount);
+ var offlineBatch = new BinaryNode("ib")
+ {
+ Content = new List
+ {
+ new("offline_batch", new Dictionary { ["count"] = batchCount.ToString() }),
+ },
+ };
+ await SendNodeAsync(offlineBatch, ct);
+ }
+ else if (child.Tag == "offline")
+ {
+ // WA sends this after delivering all offline messages.
+ var offlineCount = child.GetAttr("count") ?? "0";
+ _logger.LogInformation("offline delivery complete: {Count} items received", offlineCount);
+ }
+ else if (child.Tag == "thread_metadata")
+ {
+ // WhatsApp pushes the chat list on startup as items.
+ // Each is an active chat.
+ // JIDs may be @lid (privacy-preserving long-lived IDs) or @s.whatsapp.net or @g.us.
+ foreach (var item in child.Children)
+ {
+ if (item.Tag == "item")
+ {
+ var jid = item.GetAttr("from") ?? "";
+ var ts = item.GetAttr("t") ?? "0";
+ if (!string.IsNullOrEmpty(jid) && long.TryParse(ts, out var tsVal))
+ {
+ _threadMetadata[jid] = tsVal;
+ _logger.LogDebug("thread_metadata: chat {Jid} t={Ts}", jid, tsVal);
+ }
+ }
+ }
+ _logger.LogInformation("Cached {Count} chats from thread_metadata", _threadMetadata.Count);
+ }
+ else
+ {
+ _logger.LogDebug("Unhandled ib child: {Tag}", child.Tag);
+ }
+ }
+ }
+
+ private async Task HandleXmlStreamStartAsync(BinaryNode chat, CancellationToken ct)
+ {
+ // In the WhatsApp multi-device protocol, the server packs pair-device ref blobs
+ // directly into the first post-handshake frame as children of the xmlstreamstart node.
+ // Walk the children to find the first binary payload — that is the first QR ref.
+ byte[]? refBytes = null;
+ foreach (var container in chat.Children)
+ {
+ foreach (var child in container.Children)
+ {
+ if (child.Data is { Length: > 0 })
+ {
+ refBytes = child.Data;
+ break;
+ }
+ }
+ if (refBytes != null) break;
+ }
+
+ if (refBytes == null)
+ {
+ _logger.LogWarning("xmlstreamstart node contained no ref blobs — cannot generate QR.");
+ return;
+ }
+
+ // The ref blob is raw bytes; base64-encode it to form the ref string.
+ var ref_ = Convert.ToBase64String(refBytes);
+ var qrParts = new[]
+ {
+ ref_,
+ Convert.ToBase64String(_auth.NoiseKeyPublic),
+ Convert.ToBase64String(_auth.SignedIdentityKeyPublic),
+ Convert.ToBase64String(_auth.AdvSecretKey),
+ };
+ var qrString = string.Join(",", qrParts);
+
+ _logger.LogInformation("QR Code ready (from xmlstreamstart, ref={RefLen} bytes).", refBytes.Length);
+ QRCodeGenerated?.Invoke(this, qrString);
+ // Connection stays open — server waits for QR scan (up to ~20s per ref).
+ }
+
private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct)
{
+ // Resolve any pending IQ awaiter first
+ var iqId = iq.GetAttr("id") ?? "";
+ var iqType = iq.GetAttr("type") ?? "";
+ _logger.LogDebug("HandleIQ: id={Id} type={Type} pendingKeys=[{Keys}]",
+ iqId, iqType, string.Join(",", _pendingIqs.Keys));
+ if ((iqType == "result" || iqType == "error") && _pendingIqs.TryGetValue(iqId, out var tcs))
+ {
+ _pendingIqs.Remove(iqId);
+ _logger.LogInformation("IQ resolved: id={Id} type={Type}", iqId, iqType);
+ tcs.SetResult(iq);
+ return;
+ }
+
var type = iq.GetAttr("type");
- if (type == "result")
+ if (type == "get")
+ {
+ // Respond to keep-alive pings from the server
+ if (iq.FindChild("ping") != null)
+ {
+ var pong = new BinaryNode("iq", new()
+ {
+ ["id"] = iq.GetAttr("id") ?? "",
+ ["type"] = "result",
+ ["to"] = iq.GetAttr("from") ?? "@s.whatsapp.net",
+ });
+ await SendNodeAsync(pong, ct);
+ _logger.LogDebug("Responded to server ping id={Id}", iq.GetAttr("id"));
+ }
+ return;
+ }
+ else if (type == "result")
{
// Check for pair-device result (QR code ref)
var pairDevice = iq.FindChild("pair-device");
@@ -227,11 +563,11 @@ private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct)
return;
}
- // Check for pair-success (phone scanned QR)
+ // Check for pair-success (phone scanned QR) — also handle type="result" path
var pairSuccess = iq.FindChild("pair-success");
if (pairSuccess != null)
{
- HandlePairSuccess(pairSuccess);
+ await HandlePairSuccessAsync(iq, pairSuccess, ct);
return;
}
}
@@ -246,20 +582,39 @@ private async Task HandleIQAsync(BinaryNode iq, CancellationToken ct)
{
["id"] = iq.GetAttr("id") ?? "",
["type"] = "result",
- ["to"] = iq.GetAttr("from") ?? "s.whatsapp.net",
+ ["to"] = iq.GetAttr("from") ?? "@s.whatsapp.net",
});
await SendNodeAsync(ack, ct);
+ // Generate QR from the first ref in the pair-device node
+ await HandlePairDeviceResultAsync(pairDevice, ct);
+ }
+
+ // pair-success arrives as type="set" (server-initiated), not type="result"
+ var pairSuccess2 = iq.FindChild("pair-success");
+ if (pairSuccess2 != null)
+ {
+ await HandlePairSuccessAsync(iq, pairSuccess2, ct);
}
}
}
private async Task HandlePairDeviceResultAsync(BinaryNode pairDevice, CancellationToken ct)
{
- // Extract ref token from server
+ // Extract ref token from server — the ref is a binary blob encoded as base64 in the QR
var refNode = pairDevice.FindChild("ref");
- if (refNode?.Text == null) return;
+ if (refNode == null) return;
+
+ // ref content arrives as raw bytes that are actually a UTF-8 string
+ // (the server sends e.g. 102 ASCII chars of a base64-like token as byte[])
+ // Do NOT base64-encode it again — decode the bytes as UTF-8.
+ string ref_;
+ if (refNode.Data != null)
+ ref_ = System.Text.Encoding.UTF8.GetString(refNode.Data);
+ else if (refNode.Text != null)
+ ref_ = refNode.Text;
+ else
+ return;
- var ref_ = refNode.Text;
var qrParts = new[]
{
ref_,
@@ -273,207 +628,1798 @@ private async Task HandlePairDeviceResultAsync(BinaryNode pairDevice, Cancellati
QRCodeGenerated?.Invoke(this, qrString);
}
- private void HandlePairSuccess(BinaryNode pairSuccess)
+ private async Task HandlePairSuccessAsync(BinaryNode iq, BinaryNode pairSuccess, CancellationToken ct)
{
- _logger.LogInformation("Pairing successful! Extracting credentials.");
+ var msgId = iq.GetAttr("id") ?? "";
+ _logger.LogInformation("=== PAIR-SUCCESS RECEIVED === id={Id}", msgId);
var platform = pairSuccess.GetAttr("platform") ?? "UNKNOWN";
- _auth.Platform = platform;
-
- // In a full implementation: extract device ID, JID from the pair-success node,
- // then save them into auth state. This requires decrypting the device identity
- // proof which involves ADV (account data verification).
- // Simplified: extract JID if present
+ // Extract JID
var deviceNode = pairSuccess.FindChild("device");
- if (deviceNode != null)
+ var jid = deviceNode?.GetAttr("jid") ?? "";
+ _logger.LogInformation("Paired as {Jid} on platform {Platform}", jid, platform);
+
+ // ── ADV device-identity verification & signing ─────────────────────
+ // Baileys: configureSuccessfulPairing() in validate-connection.js
+ var devIdentityNode = pairSuccess.FindChild("device-identity");
+ if (devIdentityNode?.Data == null)
{
- var jid = deviceNode.GetAttr("jid");
- if (!string.IsNullOrEmpty(jid))
- {
- _auth.Me = new MeInfo { Id = jid };
- _logger.LogInformation("Paired as {Jid}", jid);
- }
+ _logger.LogError("pair-success missing device-identity content — cannot complete pairing.");
+ return;
}
- Authenticated?.Invoke(this, _auth);
- }
+ // 1. Decode ADVSignedDeviceIdentityHMAC
+ var hmacMsg = ADVSignedDeviceIdentityHMAC.ParseFrom(devIdentityNode.Data);
- private void HandleMessageNode(BinaryNode node)
- {
- var from = node.GetAttr("from") ?? "";
- var id = node.GetAttr("id") ?? "";
- var participant = node.GetAttr("participant");
- var pushName = node.GetAttr("notify");
- var fromMe = node.GetAttr("fromMe") == "true" || node.GetAttr("from") == _auth.Me?.Id;
+ // 2. Verify HMAC-SHA256(details, advSecretKey)
+ // isHostedAccount = (hmacMsg.AccountType == 1) => prefix [6,5], else empty
+ var isHosted = hmacMsg.AccountType == 1;
+ var hmacInput = isHosted
+ ? (new byte[] { 6, 5 }).Concat(hmacMsg.Details).ToArray()
+ : hmacMsg.Details;
+ var expectedHmac = HMACSHA256.HashData(_auth.AdvSecretKey, hmacInput);
+ if (!expectedHmac.AsSpan().SequenceEqual(hmacMsg.Hmac))
+ {
+ _logger.LogError("ADV HMAC verification failed — pairing rejected.");
+ return;
+ }
+ _logger.LogInformation("ADV HMAC verified OK.");
- if (!long.TryParse(node.GetAttr("t"), out var timestamp))
- timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ // 3. Decode ADVSignedDeviceIdentity
+ var account = ADVSignedDeviceIdentity.ParseFrom(hmacMsg.Details);
+
+ // 4. Verify account signature: XEdDSA.Verify(accountSignatureKey, [6,0] + deviceDetails + identityPub, accountSignature)
+ var accountMsg = new byte[] { 6, 0 }
+ .Concat(account.Details)
+ .Concat(_auth.SignedIdentityKeyPublic)
+ .ToArray();
+ if (!XEdDSA.Verify(account.AccountSignatureKey, accountMsg, account.AccountSignature))
+ {
+ _logger.LogError("Account signature verification failed — pairing rejected.");
+ return;
+ }
+ _logger.LogInformation("Account signature verified OK.");
+
+ // 5. Sign device identity: XEdDSA.Sign(identityPrivate, prefix + deviceDetails + identityPub + accountSigKey)
+ var devicePrefix = isHosted ? new byte[] { 6, 6 } : new byte[] { 6, 1 };
+ var deviceMsg = devicePrefix
+ .Concat(account.Details)
+ .Concat(_auth.SignedIdentityKeyPublic)
+ .Concat(account.AccountSignatureKey)
+ .ToArray();
+ account.DeviceSignature = XEdDSA.Sign(_auth.SignedIdentityKeyPrivate, deviceMsg);
+ _logger.LogInformation("Device signature created.");
- // Extract text content — walk the message body
- string? text = null;
- var body = node.FindChild("body");
- if (body?.Text != null)
- text = body.Text;
+ // Store account in auth state for device-identity node when sending pkmsg
+ _auth.Account = account.ToByteArray();
- if (string.IsNullOrEmpty(text))
- return; // Skip non-text messages for now
+ // 6. Decode ADVDeviceIdentity to get keyIndex
+ var deviceIdentity = ADVDeviceIdentity.ParseFrom(account.Details);
- var msg = new IncomingMessage
+ // 7. Re-encode ADVSignedDeviceIdentity (WITHOUT accountSignatureKey per Baileys protocol)
+ var accountEnc = account.ToByteArrayForReply();
+
+ // 8. Send pair-device-sign IQ as the result (this IS the ack — same msgId)
+ var deviceIdentityNode2 = new BinaryNode("device-identity", new()
+ {
+ ["key-index"] = deviceIdentity.KeyIndex.ToString(),
+ })
+ {
+ Content = accountEnc,
+ };
+ var pairDeviceSign = new BinaryNode("pair-device-sign")
+ {
+ Content = new List { deviceIdentityNode2 },
+ };
+ var reply = new BinaryNode("iq", new()
{
- Id = id,
- From = participant ?? from,
- RemoteJid = from,
- Participant = participant,
- Text = text,
- FromMe = fromMe,
- Timestamp = timestamp,
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "result",
+ ["id"] = msgId,
+ })
+ {
+ Content = new List { pairDeviceSign },
};
+ await SendNodeAsync(reply, ct);
+ _logger.LogInformation("pair-device-sign sent (keyIndex={KeyIndex}).", deviceIdentity.KeyIndex);
+
+ // 9. Update auth state
+ _auth.Platform = platform;
+ _auth.Me = new MeInfo { Id = jid };
- MessageReceived?.Invoke(this, msg);
+ _logger.LogInformation("Firing Authenticated event — session established.");
+ Authenticated?.Invoke(this, _auth);
}
- private async Task HandleNotificationAsync(BinaryNode notification, CancellationToken ct)
+ private void HandleMessageNode(BinaryNode node)
{
- // ACK notifications
- var id = notification.GetAttr("id");
- var to = notification.GetAttr("from") ?? "s.whatsapp.net";
- var ack = new BinaryNode("ack", new()
+ var from = node.GetAttr("from") ?? "";
+ var id = node.GetAttr("id") ?? "";
+ var participant = node.GetAttr("participant");
+ var participantPn = node.GetAttr("participant_pn"); // phone JID when participant is a LID
+ var participantLid = node.GetAttr("participant_lid"); // LID when participant/from is a phone JID
+ var senderLid = node.GetAttr("sender_lid"); // LID when from= is a phone JID
+ var senderPn = node.GetAttr("sender_pn"); // phone JID when from= is a LID
+ var pushName = node.GetAttr("notify");
+
+ // ── Learn lid↔phone mappings from ALL addressing attributes, and CRITICALLY
+ // propagate each one into the Signal store so the decrypt path resolves the
+ // LID to the same session. Without this the two maps drift and MAC verification
+ // fails for LID senders (root cause, 2026-07-03). Registering also collapses any
+ // split lid-keyed session onto the canonical phone slot before we decrypt below.
+ var cacheUpdated = false;
+ void LearnMapping(string? lid, string? phone)
{
- ["id"] = id ?? "",
- ["to"] = to,
- ["type"] = "notification",
- ["class"] = notification.GetAttr("type") ?? "",
- });
- await SendNodeAsync(ack, ct);
- }
+ if (string.IsNullOrEmpty(lid) || string.IsNullOrEmpty(phone)) return;
+ if (!lid.EndsWith("@lid") || !phone.EndsWith("@s.whatsapp.net")) return;
+ if (!_lidToPhone.TryGetValue(lid, out var ex) || ex != phone)
+ {
+ _lidToPhone[lid] = phone;
+ cacheUpdated = true;
+ }
+ // Propagate to the Signal session store (map + session migration).
+ try { _signalStore.RegisterLidMapping(lid, phone); } catch { /* best effort */ }
+ }
- // ─── Send message ───────────────────────────────────────────────────────
+ LearnMapping(participant, participantPn); // participant=LID, participant_pn=phone
+ if (from.EndsWith("@s.whatsapp.net")) LearnMapping(senderLid, from); // from=phone, sender_lid=LID
+ if (from.EndsWith("@lid")) LearnMapping(from, senderPn); // from=LID, sender_pn=phone
+ if (!string.IsNullOrEmpty(participant) && participant.EndsWith("@s.whatsapp.net"))
+ LearnMapping(participantLid, participant); // participant=phone, participant_lid=LID
- /// Sends a text message to a JID.
- public async Task SendTextMessageAsync(string jid, string text, CancellationToken ct)
- {
- var msgId = GenerateMessageId();
- var msgContent = Encoding.UTF8.GetBytes(text);
+ // Chat address for storage/display: normalize a LID `from` to its phone JID when
+ // known, so the message is stored under a phone key that getMessages(phone) finds.
+ var remoteJid = from.EndsWith("@lid") && _lidToPhone.TryGetValue(from, out var fromPhone)
+ ? fromPhone : from;
- var msgNode = new BinaryNode("message", new()
+ // Also store push names keyed by sender JID (may be LID or phone JID)
+ if (!string.IsNullOrEmpty(pushName))
{
- ["id"] = msgId,
- ["type"] = "text",
- ["to"] = jid,
- }, new List
+ var senderJid = participant ?? from;
+ if (!string.IsNullOrEmpty(senderJid) && !_pushNames.ContainsKey(senderJid))
+ {
+ _pushNames[senderJid] = pushName;
+ cacheUpdated = true;
+ }
+ // If participant is a LID, also map the phone JID if we know it
+ if (!string.IsNullOrEmpty(participantPn) && !_pushNames.ContainsKey(participantPn))
+ {
+ _pushNames[participantPn] = pushName;
+ cacheUpdated = true;
+ }
+ // If from is a phone JID and we have a sender_lid, map both
+ if (!string.IsNullOrEmpty(senderLid) && !_pushNames.ContainsKey(senderLid))
+ {
+ _pushNames[senderLid] = pushName;
+ cacheUpdated = true;
+ }
+ }
+
+ if (cacheUpdated) SaveCacheToDisk();
+
+ // Track this chat in thread metadata
+ if (!string.IsNullOrEmpty(from) && long.TryParse(node.GetAttr("t"), out var msgTs))
+ _threadMetadata[from] = msgTs;
+
+ var myJidBase = _auth.Me?.Id?.Split(':')[0]; // e.g. "31633984381"
+ var fromMe = from == myJidBase + "@s.whatsapp.net"
+ || (myJidBase != null && from.StartsWith(myJidBase));
+
+ if (!long.TryParse(node.GetAttr("t"), out var timestamp))
+ timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ // ── Encrypted path: ──
+ var participantsNode = node.FindChild("participants");
+ if (participantsNode != null)
{
- new("body", content: text),
- });
+ foreach (var toNode in participantsNode.GetChildren("to"))
+ {
+ var toJid = toNode.GetAttr("jid") ?? "";
+ if (myJidBase != null && !toJid.StartsWith(myJidBase)) continue;
- await SendNodeAsync(msgNode, ct);
- _logger.LogInformation("Sent message to {Jid}: {Text}", jid, text.Length > 50 ? text[..50] + "…" : text);
- }
+ var encNode = toNode.FindChild("enc");
+ if (encNode?.Data == null) continue;
- // ─── Low-level send/receive ─────────────────────────────────────────────
+ var encType = encNode.GetAttr("type") ?? "msg";
+ try
+ {
+ var senderJid = participant ?? from;
+ _logger.LogDebug("Participants path: decrypting encType={EncType} from {Jid}", encType, senderJid);
+ var plaintext = _signalStore.DecryptMessage(senderJid, encType, encNode.Data, _auth);
+ var waMsg = WAMessage.ParseFrom(StripSignalPadding(plaintext));
- private async Task SendNodeAsync(BinaryNode node, CancellationToken ct)
- {
- var encoded = BinaryNodeEncoder.Encode(node);
- var encrypted = EncryptFrame(encoded);
- await _socket.SendFrameAsync(encrypted, ct);
- }
+ // ── History sync notification (participants path) ─────────────────────────
+ if (waMsg.ProtocolMsg?.Type == Proto.ProtocolMessage.TYPE_HISTORY_SYNC_NOTIFICATION
+ && waMsg.ProtocolMsg.HistorySyncNotification != null)
+ {
+ _logger.LogInformation("Participants path: received HISTORY_SYNC_NOTIFICATION from {Jid}", senderJid);
+ _ = Task.Run(() => ProcessHistorySyncAsync(waMsg.ProtocolMsg.HistorySyncNotification, CancellationToken.None));
+ _ = SendAckAsync(id, from, timestamp);
+ continue;
+ }
- private bool _firstHandshakeFrame = true;
+ // ── Peer data operation response (ON_DEMAND history) ──────────────────────
+ if (waMsg.PeerDataResponse != null)
+ {
+ _logger.LogInformation("Participants path: received PeerDataOperationResponseMessage from {Jid}", senderJid);
+ _ = Task.Run(() => ProcessPeerDataResponseAsync(waMsg.PeerDataResponse, CancellationToken.None));
+ _ = SendAckAsync(id, from, timestamp);
+ continue;
+ }
- private async Task SendHandshakeMessageAsync(HandshakeMessage msg, CancellationToken ct)
- {
- var msgBytes = msg.ToByteArray();
+ // ── Skip other protocol messages (key sync, app state, etc.) ──────────────
+ if (waMsg.ProtocolMsg != null)
+ {
+ _logger.LogDebug("Participants path: protocol message type={PT} from {Jid} — skipping",
+ waMsg.ProtocolMsg.Type, senderJid);
+ _ = SendAckAsync(id, from, timestamp);
+ continue;
+ }
- if (_firstHandshakeFrame)
- {
- // Baileys wire format for the FIRST frame:
- // [WA_PROLOGUE: 4 bytes] [length: 3 bytes BE] [proto payload: N bytes]
- // The WA prologue is OUTSIDE the 3-byte length framing (unlike subsequent frames).
- var raw = new byte[WA_PROLOGUE.Length + 3 + msgBytes.Length];
- WA_PROLOGUE.CopyTo(raw, 0);
- raw[4] = (byte)(msgBytes.Length >> 16);
- raw[5] = (byte)(msgBytes.Length >> 8);
- raw[6] = (byte)(msgBytes.Length);
- msgBytes.CopyTo(raw, 7);
- await _socket.SendRawAsync(raw, ct);
- _firstHandshakeFrame = false;
- }
- else
- {
- // Subsequent frames: standard [3-byte len][payload]
- await _socket.SendFrameAsync(msgBytes, ct);
- }
- }
+ // ── Fire MessageReceived for ALL user message types ────────────────────────
+ var (msgType, text, mediaUrl, mimeType, fileName, fileSize,
+ duration, width, height, mediaKey, mediaSha256Enc,
+ reactionEmoji, reactionTargetId) = waMsg.GetAllFields();
- private byte[] EncryptFrame(byte[] data)
- {
- if (!_handshakeDone || _sendKey == null)
- throw new InvalidOperationException("Handshake not complete.");
- return AesGcmHelper.EncryptWithCounter(_sendKey, _sendCounter++, data);
- }
+ if (msgType == Messages.MessageType.Unknown)
+ {
+ _logger.LogDebug("Participants path: unknown message type from {Jid} — ACKing and skipping", senderJid);
+ _ = SendAckAsync(id, from, timestamp);
+ continue;
+ }
- private byte[] DecryptFrame(byte[] data)
- {
- if (!_handshakeDone || _recvKey == null)
- throw new InvalidOperationException("Handshake not complete.");
- return AesGcmHelper.DecryptWithCounter(_recvKey, _recvCounter++, data);
- }
+ _logger.LogInformation("Participants path: {MsgType} message from {Jid}", msgType, senderJid);
- // ─── Helpers ────────────────────────────────────────────────────────────
+ var (quotedId, quotedFrom, quotedText, quotedType) = waMsg.GetQuotedContext();
- // Baileys default version: [2, 3000, 1015920]
- // buildHash = MD5("2.3000.1015920")
- private const string WA_VERSION = "2.3000.1015920";
+ MessageReceived?.Invoke(this, new Messages.IncomingMessage
+ {
+ Id = id,
+ From = senderJid,
+ RemoteJid = remoteJid,
+ Participant = participant,
+ Type = msgType,
+ Text = text,
+ FromMe = fromMe,
+ Timestamp = timestamp,
+ PushName = pushName,
+ MediaUrl = mediaUrl,
+ MimeType = mimeType,
+ FileName = fileName,
+ FileSize = fileSize,
+ Duration = duration,
+ Width = width,
+ Height = height,
+ MediaKey = mediaKey,
+ MediaSha256Enc = mediaSha256Enc,
+ ReactionEmoji = reactionEmoji,
+ ReactionTargetId = reactionTargetId,
+ QuotedMessageId = quotedId,
+ QuotedFrom = quotedFrom,
+ QuotedText = quotedText,
+ QuotedType = quotedType,
+ });
- private byte[] BuildClientPayload()
- {
- var userAgent = new UserAgent
- {
- Platform = 14, // WEB
- AppVersion = new AppVersion { Primary = 2, Secondary = 3000, Tertiary = 1015920 },
- Mcc = "000",
- Mnc = "000",
- OsVersion = "0.1",
- Device = "Chrome", // Baileys browser[1] = "Chrome" (Browsers.ubuntu('Chrome'))
- OsBuildNumber = "0.1",
- LocaleLanguageIso6391 = "en",
- LocaleCountryIso31661Alpha2 = "US",
- };
+ _ = SendAckAsync(id, from, timestamp);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Participants path: failed to decrypt encType={EncType} from {Jid}", encType, from);
+ _ = HandleUndecryptableAsync(id, from, timestamp);
+ }
+ }
+ return;
+ }
- if (_auth.IsFresh)
+ // ── Direct (no wrapper) ──────────────────────────
+ var directEnc = node.FindChild("enc");
+ if (directEnc?.Data != null)
{
- // Fresh registration: include device pairing data so server knows our keys
- var buildHash = MD5.HashData(System.Text.Encoding.UTF8.GetBytes(WA_VERSION));
+ var encType = directEnc.GetAttr("type") ?? "msg";
+ try
+ {
+ var senderJid = participant ?? from;
+ byte[] plaintext;
+ try
+ {
+ plaintext = _signalStore.DecryptMessage(senderJid, encType, directEnc.Data, _auth);
+ }
+ catch (Exception decEx)
+ {
+ _logger.LogWarning(decEx, "Failed to decrypt direct enc message from {Jid}", from);
+ _ = HandleUndecryptableAsync(id, from, timestamp);
+ return;
+ }
- // Registration ID as 4-byte big-endian
- var eRegid = new byte[4];
- eRegid[0] = (byte)(_auth.RegistrationId >> 24);
- eRegid[1] = (byte)(_auth.RegistrationId >> 16);
- eRegid[2] = (byte)(_auth.RegistrationId >> 8);
- eRegid[3] = (byte)(_auth.RegistrationId);
+ _logger.LogInformation("Decrypted {Bytes} bytes from {Jid} (category={Category})",
+ plaintext.Length, from, node.GetAttr("category") ?? "?");
- // Signed pre-key ID as 3-byte big-endian
- var eSkeyId = new byte[3];
- eSkeyId[0] = (byte)(_auth.SignedPreKeyId >> 16);
- eSkeyId[1] = (byte)(_auth.SignedPreKeyId >> 8);
- eSkeyId[2] = (byte)(_auth.SignedPreKeyId);
+ // Strip Signal protocol padding (0x80 followed by zero bytes at end)
+ var protoBytes2 = StripSignalPadding(plaintext);
- var deviceProps = new DevicePropsMessage
- {
- Os = "Windows",
- PlatformType = 1, // CHROME
- }.ToByteArray();
+ WAMessage waMsg;
+ try
+ {
+ waMsg = WAMessage.ParseFrom(protoBytes2);
+ }
+ catch (Exception parseEx)
+ {
+ // Peer/device messages may not be standard WAMessage format — log at Warning so we can diagnose
+ _logger.LogWarning(parseEx, "Could not parse decrypted message as WAMessage from {Jid} ({Len} bytes, first={First})",
+ from, protoBytes2.Length, Convert.ToHexString(protoBytes2[..Math.Min(16, protoBytes2.Length)]));
+ _ = SendAckAsync(id, from, timestamp);
+ return;
+ }
- return new ClientPayload
- {
- Passive = false,
- Pull = false,
- ConnectType = 1, // WIFI_UNKNOWN
- ConnectReason = 1, // USER_ACTIVATED
- UserAgent = userAgent,
- WebInfo = new WebInfo { WebSubPlatform = 0 },
- DevicePairingData = new DevicePairingRegistrationData
+ var text = waMsg.GetText();
+ _logger.LogInformation("Parsed WAMessage from {Jid}: text={Text}, hasDeviceSent={DevSent}, hasSKDM={SKDM}",
+ from, text ?? "(null)", waMsg.DeviceSentMessage != null, waMsg.SenderKeyDist != null);
+
+ // ── History sync notification ─────────────────────────────────
+ if (waMsg.ProtocolMsg?.Type == Proto.ProtocolMessage.TYPE_HISTORY_SYNC_NOTIFICATION
+ && waMsg.ProtocolMsg.HistorySyncNotification != null)
+ {
+ _ = Task.Run(() => ProcessHistorySyncAsync(waMsg.ProtocolMsg.HistorySyncNotification, CancellationToken.None));
+ _ = SendAckAsync(id, from, timestamp);
+ return;
+ }
+
+ // ── Peer data operation response (ON_DEMAND history inline) ──────────
+ if (waMsg.PeerDataResponse != null)
+ {
+ _logger.LogInformation("Direct enc path: received PeerDataOperationResponseMessage from {Jid}", from);
+ _ = Task.Run(() => ProcessPeerDataResponseAsync(waMsg.PeerDataResponse, CancellationToken.None));
+ _ = SendAckAsync(id, from, timestamp);
+ return;
+ }
+
+ // ── Skip other protocol messages (key sync, app state, etc.) ──────────────
+ if (waMsg.ProtocolMsg != null)
+ {
+ _logger.LogDebug("Direct enc path: protocol message type={PT} from {Jid} — skipping",
+ waMsg.ProtocolMsg.Type, from);
+ _ = SendAckAsync(id, from, timestamp);
+ return;
+ }
+
+ // ── Fire MessageReceived for ALL user message types ────────────────────────
+ var (msgType, text2, mediaUrl, mimeType, fileName, fileSize,
+ duration, width, height, mediaKey, mediaSha256Enc,
+ reactionEmoji, reactionTargetId) = waMsg.GetAllFields();
+
+ if (msgType != Messages.MessageType.Unknown)
+ {
+ _logger.LogInformation("Direct enc path: {MsgType} message from {Jid}", msgType, from);
+ var (quotedId2, quotedFrom2, quotedText2, quotedType2) = waMsg.GetQuotedContext();
+ MessageReceived?.Invoke(this, new Messages.IncomingMessage
+ {
+ Id = id,
+ From = participant ?? from,
+ RemoteJid = remoteJid,
+ Participant = participant,
+ Type = msgType,
+ Text = text2,
+ FromMe = fromMe,
+ Timestamp = timestamp,
+ PushName = pushName,
+ MediaUrl = mediaUrl,
+ MimeType = mimeType,
+ FileName = fileName,
+ FileSize = fileSize,
+ Duration = duration,
+ Width = width,
+ Height = height,
+ MediaKey = mediaKey,
+ MediaSha256Enc = mediaSha256Enc,
+ ReactionEmoji = reactionEmoji,
+ ReactionTargetId = reactionTargetId,
+ QuotedMessageId = quotedId2,
+ QuotedFrom = quotedFrom2,
+ QuotedText = quotedText2,
+ QuotedType = quotedType2,
+ });
+ }
+ else
+ {
+ _logger.LogDebug("Direct enc path: unknown message type from {Jid} — ACKing", from);
+ }
+ _ = SendAckAsync(id, from, timestamp);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error handling direct enc message from {Jid}", from);
+ }
+ return;
+ }
+
+ // ── Fallback: plain text body (legacy) ────────────────────────────────
+ var body = node.FindChild("body");
+ var plainText = body?.Text;
+ if (string.IsNullOrEmpty(plainText)) return;
+
+ MessageReceived?.Invoke(this, new IncomingMessage
+ {
+ Id = id,
+ From = participant ?? from,
+ RemoteJid = remoteJid,
+ Text = plainText,
+ FromMe = fromMe,
+ Timestamp = timestamp,
+ PushName = pushName,
+ });
+ }
+
+ ///
+ /// Sends the passive→active switch IQ (xmlns="passive", tag="active").
+ /// This is the critical call Baileys makes after auth to tell WhatsApp to start
+ /// delivering queued messages to this companion device. Without it, WhatsApp keeps
+ /// the session in passive mode and never pushes any message/dirty/notification nodes.
+ ///
+ private async Task SendActiveAsync(CancellationToken ct)
+ {
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "set",
+ ["xmlns"] = "passive",
+ ["id"] = GenerateMessageId(),
+ })
+ {
+ Content = new List { new BinaryNode("active") },
+ };
+ try
+ {
+ await SendIQAsync(iq, ct, timeoutMs: 10000);
+ _logger.LogInformation("Passive→active switch sent — WhatsApp should now deliver messages.");
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning("passive/active IQ timed out — WhatsApp may not deliver messages.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "passive/active IQ failed.");
+ }
+ }
+
+ ///
+ /// Sends a presence update to tell WhatsApp this device is online and ready to receive messages.
+ /// Without this, WhatsApp may not deliver messages to linked devices.
+ ///
+ private async Task SendPresenceAsync(CancellationToken ct)
+ {
+ try
+ {
+ var presence = new BinaryNode("presence", new Dictionary
+ {
+ ["type"] = "available",
+ });
+ await SendNodeAsync(presence, ct);
+ _logger.LogInformation("Sent presence: available — WhatsApp should now deliver messages to this device.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send presence update");
+ }
+ }
+
+ ///
+ /// Sends a keepalive ping IQ every 25 seconds, like Baileys does after login.
+ /// The server expects this to consider the device fully online and deliver messages.
+ ///
+ private async Task KeepAliveLoopAsync(CancellationToken ct)
+ {
+ const int intervalMs = 25_000; // ping cadence
+ const int deadAfterMs = intervalMs + 15_000; // ~40s of total silence => declare dead
+ _logger.LogInformation("Keepalive loop started ({Interval}s interval, dead after {Dead}s silence).",
+ intervalMs / 1000, deadAfterMs / 1000);
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(intervalMs, ct);
+ if (ct.IsCancellationRequested) break;
+
+ // Dead-connection watchdog: if we have not received ANY frame (including the
+ // pong to our own ping) for too long, the socket is dead/half-open. Dispose it
+ // so the parked ReceiveAsync throws, the receive loop exits, and reconnection runs.
+ var silentMs = (DateTime.UtcNow.Ticks - System.Threading.Interlocked.Read(ref _lastRecvTicks))
+ / TimeSpan.TicksPerMillisecond;
+ if (silentMs > deadAfterMs)
+ {
+ _logger.LogWarning("Keepalive: no data for {Ms}ms — declaring connection dead, tearing down socket.", silentMs);
+ try { await _socket.DisposeAsync(); } catch { /* best effort */ }
+ break;
+ }
+
+ try
+ {
+ var ping = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "get",
+ ["xmlns"] = "w:p",
+ })
+ {
+ Content = new List { new("ping") },
+ };
+ await SendNodeAsync(ping, ct);
+ _logger.LogDebug("Sent keepalive ping.");
+ }
+ catch (Exception ex)
+ {
+ // A ping WRITE failure means the socket is gone. Tear it down so the receive
+ // loop unblocks and reconnection runs (previously it just broke this loop,
+ // leaving the receive loop parked forever).
+ _logger.LogWarning(ex, "Keepalive ping write failed — tearing down socket to force reconnect.");
+ try { await _socket.DisposeAsync(); } catch { /* best effort */ }
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ _logger.LogInformation("Keepalive loop ended.");
+ }
+
+ private async Task SendAckAsync(string msgId, string to, long timestamp)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var ack = new BinaryNode("ack", new Dictionary
+ {
+ ["id"] = msgId,
+ ["to"] = to,
+ ["type"] = "message",
+ ["t"] = timestamp.ToString(),
+ });
+ await SendNodeAsync(ack, cts.Token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send ACK for message {Id}", msgId);
+ }
+ }
+
+ ///
+ /// Sends a retry receipt to WhatsApp, asking the sender to re-encrypt using
+ /// a fresh pre-key bundle. Called when we receive a message we cannot decrypt
+ /// (e.g. our session state was lost after a restart).
+ ///
+ ///
+ /// Decides how to handle a message we could not decrypt. For a RECENT (live) message we
+ /// send a retry receipt so the sender re-keys and we recover it. For an OLD message from
+ /// the offline backlog (a dead ratchet chain that will never decrypt) we simply ACK it so
+ /// WhatsApp stops resending it and the offline queue drains quickly to current messages,
+ /// instead of looping forever on undecryptable history. Old messages are already on every
+ /// participant's phone, so nothing important is lost. This keeps the bridge current — the
+ /// behaviour a healthy client (e.g. Baileys) gets for free by never accumulating a backlog.
+ ///
+ private async Task HandleUndecryptableAsync(string id, string from, long timestamp)
+ {
+ const long BacklogThresholdSeconds = 2 * 3600; // messages older than 2h = offline backlog
+ var ageSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp;
+ if (timestamp > 0 && ageSeconds > BacklogThresholdSeconds)
+ {
+ _logger.LogInformation("Undecryptable BACKLOG message {Id} from {From} (age {Hours}h) — ACK to drain queue, no retry.", id, from, ageSeconds / 3600);
+ await SendAckAsync(id, from, timestamp);
+ }
+ else
+ {
+ await SendRetryReceiptAsync(id, from, timestamp);
+ }
+ }
+
+ public Task SendManualRetryReceiptAsync(string to, string msgId, long timestamp, CancellationToken ct) =>
+ SendRetryReceiptAsync(msgId, to, timestamp);
+
+ // Per-message retry-receipt attempt counter. A message we can never decrypt (dead
+ // ratchet chain after a long offline gap) otherwise loops forever: fail → retry
+ // receipt → resend → fail, starving delivery of every other message behind it in the
+ // offline queue. After MaxRetryAttempts we ACK the message instead, so WhatsApp stops
+ // resending it and the queue drains to newer/live messages.
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _retryAttempts = new();
+ private const int MaxRetryAttempts = 5;
+
+ private async Task SendRetryReceiptAsync(string msgId, string to, long timestamp)
+ {
+ var attempt = _retryAttempts.AddOrUpdate(msgId, 1, (_, n) => n + 1);
+ if (attempt > MaxRetryAttempts)
+ {
+ _retryAttempts.TryRemove(msgId, out _);
+ _logger.LogWarning("Retry cap ({Max}) reached for {MsgId} — undecryptable, ACKing to drain offline queue.", MaxRetryAttempts, msgId);
+ await SendAckAsync(msgId, to, timestamp);
+ return;
+ }
+
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var retry = new BinaryNode("receipt", new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "retry",
+ ["to"] = to,
+ ["t"] = timestamp.ToString(),
+ })
+ {
+ Content = new List
+ {
+ new("retry", new Dictionary
+ {
+ ["count"] = attempt.ToString(),
+ ["id"] = msgId,
+ ["t"] = timestamp.ToString(),
+ ["v"] = "1",
+ }),
+ new("registration", null, new byte[] { (byte)(_auth.RegistrationId >> 24), (byte)(_auth.RegistrationId >> 16), (byte)(_auth.RegistrationId >> 8), (byte)_auth.RegistrationId }),
+ // — our identity + signed pre-key + one-time pre-key so the sender can
+ // re-establish a fresh Signal session. MUST include (one-time pre-key) —
+ // without it the phone ACKs the retry receipt but never re-encrypts the message.
+ BuildRetryKeysNode(_auth),
+ },
+ };
+ await SendNodeAsync(retry, cts.Token);
+ _logger.LogInformation("Sent retry receipt for {MsgId} to {To} — session state lost, requesting re-key.", msgId, to);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to send retry receipt for {MsgId}", msgId);
+ }
+ }
+
+ /// Builds a node from our signed pre-key — included in retry receipts so the
+ /// sender can re-encrypt using a fresh Signal session with our current keys.
+ private static BinaryNode BuildSignedPreKeyNode(Auth.AuthState auth)
+ {
+ var spkId = auth.SignedPreKeyId;
+ var idBytes = new byte[] { (byte)(spkId >> 16), (byte)(spkId >> 8), (byte)spkId };
+ return new BinaryNode("skey", null, new List
+ {
+ new("id", null, idBytes),
+ new("value", null, auth.SignedPreKeyPublic),
+ new("signature", null, auth.SignedPreKeySignature),
+ });
+ }
+
+ /// Builds the node for retry receipts.
+ /// Node order matches Baileys: type, identity, key (one-time), skey (signed), device-identity.
+ /// The device-identity is REQUIRED for the phone to trust and process the retry receipt.
+ /// Uses _retryPreKeyIndex to assign a unique pre-key per message so that if multiple
+ /// re-encrypted pkmsg responses arrive they can each be decrypted independently.
+ private BinaryNode BuildRetryKeysNode(Auth.AuthState auth)
+ {
+ static byte[] Be3(uint v) => [(byte)(v >> 16), (byte)(v >> 8), (byte)v];
+
+ var children = new List
+ {
+ new("type", null, new byte[] { 5 }), // DJB_TYPE = 0x05
+ new("identity", null, auth.SignedIdentityKeyPublic),
+ };
+
+ // Pick a unique one-time pre-key for each retry receipt. _retryPreKeyIndex increments
+ // per call so that each re-encrypted pkmsg response uses a different X3DH pre-key and
+ // can be decrypted independently (keys are consumed by InitIncomingSession on use).
+ var keyIndex = _retryPreKeyIndex++;
+ var otpk = keyIndex < auth.PreKeys.Count ? auth.PreKeys[keyIndex] : auth.PreKeys.LastOrDefault();
+ if (otpk != null)
+ {
+ // comes BEFORE — this matches Baileys xmppPreKey / xmppSignedPreKey order
+ children.Add(new BinaryNode("key", null, new List
+ {
+ new("id", null, Be3(otpk.Id)),
+ new("value", null, otpk.Public),
+ }));
+ }
+
+ // (signed pre-key) AFTER
+ children.Add(BuildSignedPreKeyNode(auth));
+
+ // device-identity — REQUIRED! Phone uses this to verify our device and re-establish session.
+ if (auth.Account != null)
+ {
+ children.Add(new BinaryNode("device-identity") { Content = auth.Account });
+ }
+
+ return new BinaryNode("keys", null, children);
+ }
+
+ ///
+ /// Sends the initial app state sync request (xmlns="w:app:state:sync").
+ /// Baileys calls resyncAppState immediately after auth — WhatsApp may withhold
+ /// message delivery until the companion device has "checked in" with a sync request.
+ /// We request all 5 standard collections at version=0 (initial sync).
+ /// We don't need to process the response content, just sending the request is enough
+ /// to signal to WhatsApp that this device is ready to receive messages.
+ ///
+ private async Task SendAppStateSyncAsync(CancellationToken ct)
+ {
+ var collections = new[] { "critical_block", "critical_unblock_to_primary", "regular_high", "regular_low", "regular" };
+ var collectionNodes = collections.Select(name => new BinaryNode("collection", new Dictionary
+ {
+ ["name"] = name,
+ ["version"] = "0",
+ ["return_snapshot"] = "true",
+ })).ToList();
+
+ var syncNode = new BinaryNode("sync") { Content = collectionNodes };
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "@s.whatsapp.net",
+ ["xmlns"] = "w:app:state:sync",
+ ["type"] = "set",
+ ["id"] = GenerateMessageId(),
+ }) { Content = new List { syncNode } };
+
+ try
+ {
+ var result = await SendIQAsync(iq, ct, timeoutMs: 60000);
+ _logger.LogInformation("App state sync request acknowledged (WhatsApp should now route messages to this device).");
+ }
+ catch (TimeoutException ex)
+ {
+ _logger.LogWarning(ex, "App state sync IQ timed out — continuing anyway (messages may still arrive)");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "App state sync request failed (non-fatal)");
+ }
+ }
+
+ // Cached app-state collection versions received via server_sync notifications.
+ // key = collection name (e.g. "contact", "regular_low"), value = version number
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _serverSyncVersions = new();
+
+ // Thread metadata from nodes — key=JID (may be @lid), value=unix timestamp
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _threadMetadata = new();
+
+ // LID → phone JID mapping — populated from participant_pn attributes in incoming messages
+ // key = "178430138150925@lid", value = "31633984381@s.whatsapp.net"
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _lidToPhone = new();
+
+ // JID → push name — populated from "notify" attribute of incoming messages
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _pushNames = new();
+
+ private async Task HandleNotificationAsync(BinaryNode notification, CancellationToken ct)
+ {
+ var id = notification.GetAttr("id") ?? "";
+ var type = notification.GetAttr("type") ?? "";
+ var to = notification.GetAttr("from") ?? "s.whatsapp.net";
+
+ _logger.LogInformation("Notification: type={Type} id={Id} from={From}", type, id, to);
+
+ // ACK the notification first
+ var ack = new BinaryNode("ack", new()
+ {
+ ["id"] = id,
+ ["to"] = to,
+ ["type"] = "notification",
+ ["class"] = type,
+ });
+ await SendNodeAsync(ack, ct);
+
+ // --- Handle server_sync notifications ---
+ // WhatsApp sends these in response to w:app:state:sync IQs (instead of IQ results).
+ // They tell us the current version of each collection.
+ if (type == "server_sync")
+ {
+ var children = notification.Children;
+ foreach (var child in children)
+ {
+ if (child.Tag == "collection")
+ {
+ var name = child.GetAttr("name") ?? "";
+ var verStr = child.GetAttr("version") ?? "0";
+ if (int.TryParse(verStr, out var ver))
+ {
+ _serverSyncVersions[name] = ver;
+ _logger.LogInformation("server_sync: collection={Name} version={Ver}", name, ver);
+ }
+ }
+ }
+ }
+
+ // Generic: if any notification has an ID matching a pending IQ, resolve it.
+ // WhatsApp sometimes sends IQ responses as notifications (e.g. w:app:state:sync, potentially w:m).
+ if (!string.IsNullOrEmpty(id) && _pendingIqs.TryGetValue(id, out var pendingTcs))
+ {
+ _pendingIqs.Remove(id);
+ _logger.LogInformation("Resolved pending IQ {Id} via {Type} notification", id, type);
+ pendingTcs.TrySetResult(notification);
+ }
+ }
+
+ // ─── Send message ───────────────────────────────────────────────────────
+
+ /// Sends an encrypted text message to a JID using Signal Protocol.
+ public async Task SendTextMessageAsync(string jid, string text, CancellationToken ct)
+ {
+ // 1. Normalize JID
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0];
+
+ // 2. Get recipient device list via USync
+ List recipientDeviceJids;
+ try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber);
+ recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"];
+ }
+
+ // 3. Get sender's own devices for multi-device sync
+ var senderDeviceJids = new List();
+ var myJid = _auth.Me?.Id; // e.g. "31633984381:44@s.whatsapp.net"
+ if (myJid != null)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+ try
+ {
+ var myDevices = await GetDeviceListAsync(myPhone, ct);
+ foreach (var d in myDevices)
+ {
+ // Skip our own companion device (can't encrypt to ourselves)
+ if (d == myJid) continue;
+ senderDeviceJids.Add(d);
+ }
+ _logger.LogInformation("Sender has {Count} other devices for sync: {Jids}",
+ senderDeviceJids.Count, string.Join(", ", senderDeviceJids));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get sender device list, skipping multi-device sync");
+ }
+ }
+
+ // Combine all device JIDs for pre-key bundle fetching
+ var allDeviceJids = new List(recipientDeviceJids);
+ allDeviceJids.AddRange(senderDeviceJids);
+
+ // 4. For each device without a session, fetch pre-key bundle
+ var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList();
+ if (needBundles.Count > 0)
+ {
+ try
+ {
+ _logger.LogInformation("Fetching pre-key bundles for {Count} devices: {Jids}",
+ needBundles.Count, string.Join(", ", needBundles));
+ var bundles = await FetchPreKeyBundlesAsync(needBundles, ct);
+ _logger.LogInformation("Got {Count} bundles back", bundles.Count);
+ foreach (var (deviceJid, bundle) in bundles)
+ {
+ try
+ {
+ _signalStore.InitOutgoingSession(deviceJid, bundle, _auth);
+ _logger.LogInformation("Initialized outgoing session for {Jid}", deviceJid);
+ }
+ catch (Exception ex2)
+ {
+ _logger.LogWarning(ex2, "Failed to init outgoing session for {Jid}", deviceJid);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to fetch pre-key bundles");
+ }
+ }
+
+ // 5. Proto-encode the message with random PKCS7-style padding (Baileys: writeRandomPadMax16)
+ var msgRaw = new WAMessage { Conversation = text }.ToByteArray();
+
+ // 6. Build padded proto for recipient devices (direct message)
+ var recipientProto = PadMessage(msgRaw);
+
+ // 7. Build padded proto for sender's own devices (wrapped in DeviceSentMessage)
+ byte[]? senderProto = null;
+ if (senderDeviceJids.Count > 0)
+ {
+ var deviceSentMsg = new WAMessage
+ {
+ DeviceSentMessage = new DeviceSentMessage
+ {
+ DestinationJid = normalizedJid,
+ Message = new WAMessage { Conversation = text },
+ }
+ };
+ senderProto = PadMessage(deviceSentMsg.ToByteArray());
+ }
+
+ // 8. Encrypt for each device
+ var msgId = GenerateMessageId();
+ var hasPkMsg = false;
+ var toNodes = new List();
+
+ // Encrypt for recipient devices
+ foreach (var deviceJid in recipientDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+
+ // Encrypt for sender's own devices (multi-device sync)
+ foreach (var deviceJid in senderDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, senderProto!, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+
+ if (toNodes.Count == 0)
+ {
+ var reason = $"No devices encrypted successfully for {normalizedJid} — message NOT sent (stale/missing Signal session or pre-key bundle fetch failed).";
+ _logger.LogError(reason);
+ throw new InvalidOperationException(reason);
+ }
+
+ // 9. Build and send message node
+ var participantsNode = new BinaryNode("participants")
+ {
+ Content = toNodes,
+ };
+ var contentNodes = new List { participantsNode };
+
+ // Include device-identity when sending pkmsg (first message to a device)
+ if (hasPkMsg && _auth.Account != null)
+ {
+ var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account);
+ var deviceIdentityBytes = accountProto.ToByteArray();
+ contentNodes.Add(new BinaryNode("device-identity") { Content = deviceIdentityBytes });
+ _logger.LogInformation("Including device-identity in message (pkmsg detected, {Len} bytes)", deviceIdentityBytes.Length);
+ }
+ else if (hasPkMsg)
+ {
+ _logger.LogWarning("pkmsg detected but Account is NULL — device-identity NOT included. Recipient may reject. Re-pair to fix.");
+ }
+
+ var msgAttrs = new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "text",
+ ["to"] = normalizedJid,
+ };
+
+ var msgNode = new BinaryNode("message", msgAttrs) { Content = contentNodes };
+
+ await SendNodeAsync(msgNode, ct);
+ _logger.LogInformation("Sent encrypted message to {Jid} via {RecipientCount}+{SenderCount} devices",
+ normalizedJid, recipientDeviceJids.Count, senderDeviceJids.Count);
+ }
+
+ ///
+ /// Sends an emoji reaction to a specific message.
+ ///
+ /// The chat JID (e.g. "31612345678@s.whatsapp.net") containing the target message.
+ /// The ID of the message to react to.
+ /// Whether the target message was sent by us.
+ /// The reaction emoji, e.g. "👍". Pass "" to remove an existing reaction.
+ public async Task SendReactionAsync(string targetJid, string targetMessageId, bool targetFromMe, string emoji, CancellationToken ct)
+ {
+ var normalizedJid = targetJid.Contains('@') ? targetJid : $"{targetJid.TrimStart('+')}@s.whatsapp.net";
+ var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0];
+
+ // Get recipient device list
+ List recipientDeviceJids;
+ try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber);
+ recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"];
+ }
+
+ // Get sender's own devices for multi-device sync
+ var senderDeviceJids = new List();
+ var myJid = _auth.Me?.Id;
+ if (myJid != null)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+ try
+ {
+ var myDevices = await GetDeviceListAsync(myPhone, ct);
+ foreach (var d in myDevices)
+ {
+ if (d == myJid) continue;
+ senderDeviceJids.Add(d);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get sender device list for reaction, skipping multi-device sync");
+ }
+ }
+
+ var allDeviceJids = new List(recipientDeviceJids);
+ allDeviceJids.AddRange(senderDeviceJids);
+
+ // Fetch pre-key bundles for devices without sessions
+ var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList();
+ if (needBundles.Count > 0)
+ {
+ try
+ {
+ var bundles = await FetchPreKeyBundlesAsync(needBundles, ct);
+ foreach (var (deviceJid, bundle) in bundles)
+ {
+ try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); }
+ catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); }
+ }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles for reaction"); }
+ }
+
+ // Build reaction proto
+ var reactionProto = new WAMessage
+ {
+ ReactionMessage = new Dawa.Proto.ReactionMessage
+ {
+ Key = new Dawa.Proto.MessageKey
+ {
+ RemoteJid = normalizedJid,
+ FromMe = targetFromMe,
+ Id = targetMessageId,
+ },
+ Text = emoji,
+ SenderTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }
+ };
+
+ var recipientProto = PadMessage(reactionProto.ToByteArrayWithReaction());
+
+ // For own devices, same payload (reactions are not wrapped in DeviceSentMessage)
+ byte[]? senderProto = senderDeviceJids.Count > 0 ? recipientProto : null;
+
+ var msgId = GenerateMessageId();
+ var hasPkMsg = false;
+ var toNodes = new List();
+
+ foreach (var deviceJid in recipientDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+ foreach (var deviceJid in senderDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, senderProto!, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+
+ if (toNodes.Count == 0)
+ {
+ var reason = $"No devices encrypted successfully for reaction to {normalizedJid} — reaction NOT sent (stale/missing Signal session or pre-key bundle fetch failed).";
+ _logger.LogError(reason);
+ throw new InvalidOperationException(reason);
+ }
+
+ var participantsNode = new BinaryNode("participants") { Content = toNodes };
+ var contentNodes = new List { participantsNode };
+
+ if (hasPkMsg && _auth.Account != null)
+ {
+ var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account);
+ var deviceIdentityBytes = accountProto.ToByteArray();
+ contentNodes.Add(new BinaryNode("device-identity") { Content = deviceIdentityBytes });
+ }
+
+ var msgNode = new BinaryNode("message", new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "reaction",
+ ["to"] = normalizedJid,
+ }) { Content = contentNodes };
+
+ await SendNodeAsync(msgNode, ct);
+ _logger.LogInformation("Sent reaction '{Emoji}' to message {MsgId} in {Jid}", emoji, targetMessageId, normalizedJid);
+ }
+
+ ///
+ /// Sends a media message (image, audio, or document) to a JID.
+ /// must be "image", "audio", or "document".
+ /// is the raw (unencrypted) file bytes.
+ /// e.g. "image/jpeg", "audio/ogg; codecs=opus", "application/pdf".
+ /// optional text (images only).
+ /// optional file name (documents).
+ ///
+ public async Task SendMediaAsync(string jid, byte[] fileBytes, string mediaType, string mimeType,
+ string caption, string fileName, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0];
+
+ // 1. Encrypt the media
+ var enc = Dawa.Crypto.MediaCrypto.Encrypt(fileBytes, mediaType);
+ _logger.LogInformation("Media encrypted: {Bytes} → {EncBytes} bytes for {Type}", fileBytes.Length, enc.EncryptedBytes.Length, mediaType);
+
+ // 2. Get upload URL from WhatsApp server via w:m IQ (Baileys pattern)
+ var encSha256B64 = Convert.ToBase64String(enc.FileEncSha256);
+ var (mediaUrl, directPath, uploadAuth) = await RequestMediaUploadUrlAsync(mediaType, enc.EncryptedBytes.Length, encSha256B64, ct);
+ _logger.LogInformation("Media upload URL: {Url} directPath={DP}", mediaUrl, directPath);
+
+ // 3. Upload encrypted bytes to CDN (raw binary, application/octet-stream).
+ // The content is encrypted so the actual MIME type is irrelevant; CDN accepts octet-stream.
+ // URL already contains ?auth=...&token=... from RequestMediaUploadUrlAsync.
+ var rawContent = new ByteArrayContent(enc.EncryptedBytes);
+ rawContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
+
+ using var uploadRequest = new HttpRequestMessage(HttpMethod.Post, mediaUrl)
+ {
+ Content = rawContent,
+ };
+ uploadRequest.Headers.TryAddWithoutValidation("Origin", "https://web.whatsapp.com");
+ uploadRequest.Headers.TryAddWithoutValidation("Referer", "https://web.whatsapp.com/");
+
+ // Diagnostic: write full URL + encrypted bytes to disk so we can reproduce the upload via curl
+ try {
+ var diagDir = @"C:\inetpub\whatsappbridge-api\logs";
+ System.IO.File.WriteAllText(System.IO.Path.Combine(diagDir, "cdn-upload-debug.txt"),
+ $"URL: {mediaUrl}\nEncBytes: {enc.EncryptedBytes.Length}\nContentType: application/octet-stream\nToken: {encSha256B64.Replace('+','-').Replace('/','_').TrimEnd('=')}\nPlain b64: {encSha256B64}\n");
+ System.IO.File.WriteAllBytes(System.IO.Path.Combine(diagDir, "cdn-upload-bytes.bin"), enc.EncryptedBytes);
+ } catch { }
+ _logger.LogInformation("Uploading {Bytes} bytes ({Mime}) to CDN: {Url}", enc.EncryptedBytes.Length, mimeType, mediaUrl);
+ var uploadResp = await _http.SendAsync(uploadRequest, ct);
+ var respBody = await uploadResp.Content.ReadAsStringAsync(ct);
+ _logger.LogInformation("CDN upload response: {Status} body={Body}", uploadResp.StatusCode, respBody);
+
+ // Diagnostic: capture CDN response to disk for offline analysis
+ try {
+ var diagDir = @"C:\inetpub\whatsappbridge-api\logs";
+ var respHeaders = string.Join("\n", uploadResp.Headers.Select(h => $"{h.Key}: {string.Join(", ", h.Value)}"))
+ + "\n" + string.Join("\n", uploadResp.Content.Headers.Select(h => $"{h.Key}: {string.Join(", ", h.Value)}"));
+ System.IO.File.WriteAllText(System.IO.Path.Combine(diagDir, "cdn-upload-response.txt"),
+ $"Status: {(int)uploadResp.StatusCode} {uploadResp.StatusCode}\nTimestamp: {DateTime.UtcNow:u}\n--- Response Headers ---\n{respHeaders}\n--- Response Body ---\n{respBody}\n");
+ } catch { }
+
+ if (!uploadResp.IsSuccessStatusCode)
+ throw new InvalidOperationException($"CDN upload failed: {uploadResp.StatusCode} - {respBody[..Math.Min(200, respBody.Length)]}");
+
+ // Parse CDN JSON response for final url + directPath
+ var cdnJson = respBody;
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(cdnJson);
+ var root = doc.RootElement;
+ if (root.TryGetProperty("url", out var urlProp) && urlProp.GetString() is { Length: > 0 } finalUrl)
+ mediaUrl = finalUrl;
+ if (root.TryGetProperty("direct_path", out var dpProp) && dpProp.GetString() is { Length: > 0 } finalDp)
+ directPath = finalDp;
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse CDN response JSON"); }
+ _logger.LogInformation("Media uploaded: url={Url} directPath={DP}", mediaUrl, directPath);
+
+ // 4. Build media proto node
+ WAMessage mediaMsg;
+ switch (mediaType)
+ {
+ case "image":
+ mediaMsg = new WAMessage
+ {
+ ImageMessage = new Dawa.Proto.ImageMessage
+ {
+ Url = mediaUrl,
+ DirectPath = directPath,
+ MimeType = mimeType,
+ Caption = caption,
+ FileSha256 = enc.FileSha256,
+ FileEncSha256 = enc.FileEncSha256,
+ FileLength = (ulong)enc.FileLength,
+ MediaKey = enc.MediaKey,
+ MediaKeyTimestamp = enc.MediaKeyTimestamp,
+ }
+ };
+ break;
+ case "audio":
+ mediaMsg = new WAMessage
+ {
+ AudioMessage = new Dawa.Proto.AudioMessage
+ {
+ Url = mediaUrl,
+ DirectPath = directPath,
+ MimeType = mimeType,
+ FileSha256 = enc.FileSha256,
+ FileEncSha256 = enc.FileEncSha256,
+ FileLength = (ulong)enc.FileLength,
+ MediaKey = enc.MediaKey,
+ MediaKeyTimestamp = enc.MediaKeyTimestamp,
+ Ptt = mimeType.Contains("ogg"),
+ }
+ };
+ break;
+ default: // document
+ mediaMsg = new WAMessage
+ {
+ DocumentMessage = new Dawa.Proto.DocumentMessage
+ {
+ Url = mediaUrl,
+ DirectPath = directPath,
+ MimeType = mimeType,
+ Title = string.IsNullOrEmpty(caption) ? fileName : caption,
+ FileName = fileName,
+ FileSha256 = enc.FileSha256,
+ FileEncSha256 = enc.FileEncSha256,
+ FileLength = (ulong)enc.FileLength,
+ MediaKey = enc.MediaKey,
+ MediaKeyTimestamp = enc.MediaKeyTimestamp,
+ }
+ };
+ break;
+ }
+
+ // 5. Get device lists (same as text/reaction)
+ List recipientDeviceJids;
+ try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get device list for {Phone}, using fallback", phoneNumber);
+ recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"];
+ }
+
+ var senderDeviceJids = new List();
+ var myJid = _auth.Me?.Id;
+ if (myJid != null)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+ try
+ {
+ var myDevices = await GetDeviceListAsync(myPhone, ct);
+ foreach (var d in myDevices)
+ {
+ if (d == myJid) continue;
+ senderDeviceJids.Add(d);
+ }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to get sender device list for media"); }
+ }
+
+ var allDeviceJids = new List(recipientDeviceJids);
+ allDeviceJids.AddRange(senderDeviceJids);
+
+ var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList();
+ if (needBundles.Count > 0)
+ {
+ try
+ {
+ var bundles = await FetchPreKeyBundlesAsync(needBundles, ct);
+ foreach (var (deviceJid, bundle) in bundles)
+ {
+ try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); }
+ catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); }
+ }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles for media"); }
+ }
+
+ // 6. Encrypt for each device
+ var recipientProto = PadMessage(mediaMsg.ToByteArrayWithMedia());
+ byte[]? senderProto = null;
+ if (senderDeviceJids.Count > 0)
+ {
+ var deviceSentMsg = new WAMessage
+ {
+ DeviceSentMessage = new Dawa.Proto.DeviceSentMessage
+ {
+ DestinationJid = normalizedJid,
+ Message = mediaMsg,
+ }
+ };
+ senderProto = PadMessage(deviceSentMsg.ToByteArray());
+ }
+
+ var msgId = GenerateMessageId();
+ var hasPkMsg = false;
+ var toNodes = new List();
+
+ foreach (var deviceJid in recipientDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, recipientProto, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+ if (senderProto != null)
+ {
+ foreach (var deviceJid in senderDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, senderProto, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+ }
+
+ if (toNodes.Count == 0)
+ {
+ var reason = $"No devices encrypted for media to {normalizedJid} — media NOT sent (stale/missing Signal session or pre-key bundle fetch failed).";
+ _logger.LogError(reason);
+ throw new InvalidOperationException(reason);
+ }
+
+ // 7. Build and send message node
+ var participantsNode = new BinaryNode("participants") { Content = toNodes };
+ var contentNodes = new List { participantsNode };
+
+ if (hasPkMsg && _auth.Account != null)
+ {
+ var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account);
+ contentNodes.Add(new BinaryNode("device-identity") { Content = accountProto.ToByteArray() });
+ }
+
+ var msgNode = new BinaryNode("message", new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "media",
+ ["to"] = normalizedJid,
+ }) { Content = contentNodes };
+
+ await SendNodeAsync(msgNode, ct);
+ _logger.LogInformation("Sent {Type} media to {Jid} ({Bytes} bytes)", mediaType, normalizedJid, fileBytes.Length);
+ }
+
+ ///
+ /// Requests a media upload URL from WhatsApp.
+ /// Tries the Baileys w:m IQ (type=set, media_conn) first.
+ /// Falls back to direct mmg.whatsapp.net upload URL if the IQ times out
+ /// (the server silently ignores w:m IQs for companion device sessions).
+ /// Returns (uploadUrl, directPath, authToken).
+ ///
+ private async Task<(string Url, string DirectPath, string Auth)> RequestMediaUploadUrlAsync(
+ string mediaType, long encryptedSize, string fileEncSha256B64, CancellationToken ct)
+ {
+ // --- Attempt 1: Baileys exact pattern (type=set, inner , to=s.whatsapp.net) ---
+ try
+ {
+ var mediaConnNode = new BinaryNode("media_conn", new Dictionary());
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["to"] = "s.whatsapp.net", // no @, encodes as dict token — matches Baileys S_WHATSAPP_NET
+ ["xmlns"] = "w:m",
+ ["type"] = "set",
+ }) { Content = new List { mediaConnNode } };
+
+ _logger.LogInformation("Requesting media_conn via w:m set IQ (Baileys pattern)");
+ var result = await SendIQAsync(iq, ct, timeoutMs: 8000);
+
+ // Response:
+ var mcNode = result.FindChild("media_conn") ?? FindDeep(result, "media_conn");
+ if (mcNode != null)
+ {
+ var auth = mcNode.GetAttr("auth") ?? "";
+ // Pick first host that has an child; fall back to first host
+ var uploadHost = mcNode.Children.FirstOrDefault(h => h.Tag == "host" && h.Children.Any(c => c.Tag == "upload"))
+ ?? mcNode.Children.FirstOrDefault(h => h.Tag == "host");
+ var hostName = uploadHost?.GetAttr("hostname") ?? "mmg.whatsapp.net";
+ // Token: base64url (RFC 4648) — Baileys uses: replace +→- /→_ strip =, then encodeURIComponent.
+ // Result is already URL-safe so encodeURIComponent is a no-op for these chars.
+ var tokenB64 = Uri.EscapeDataString(fileEncSha256B64.Replace('+', '-').Replace('/', '_').TrimEnd('='));
+ // Baileys URL: https://{host}/mms/{type}/{token}?auth={auth}&token={token}
+ // The token appears both in the URL PATH and as a query param.
+ var uploadUrl = $"https://{hostName}/mms/{mediaType}/{tokenB64}?auth={Uri.EscapeDataString(auth)}&token={tokenB64}";
+ _logger.LogInformation("Got media_conn auth, upload to {Host}", hostName);
+ return (uploadUrl, "", auth);
+ }
+
+ // IQ returned something but no media_conn child — log and fall through
+ _logger.LogWarning("w:m IQ result had no media_conn child, falling back to direct upload");
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning("w:m IQ timed out (server ignores companion device w:m IQs) — using direct mmg upload");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "w:m IQ failed — using direct mmg upload");
+ }
+
+ // --- Fallback: direct upload to mmg.whatsapp.net without auth token ---
+ // Some companion device implementations upload directly using the encrypted hash as token.
+ // URL format used by open-source WA clients when no media_conn auth is available.
+ var hashB64Url = Uri.EscapeDataString(fileEncSha256B64.Replace('+', '-').Replace('/', '_').TrimEnd('='));
+ var directUploadUrl = $"https://mmg.whatsapp.net/mms/{mediaType}/{hashB64Url}?hash={hashB64Url}&type={mediaType}&v=4";
+ _logger.LogInformation("Using direct mmg upload URL for {Type}", mediaType);
+ return (directUploadUrl, "", "");
+ }
+
+ /// Pads a proto-encoded message with random PKCS7-style padding (Baileys: writeRandomPadMax16).
+ private static byte[] PadMessage(byte[] msgRaw)
+ {
+ var padLen = (byte)(System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 16) + 1); // 1-16
+ var padded = new byte[msgRaw.Length + padLen];
+ msgRaw.CopyTo(padded, 0);
+ Array.Fill(padded, padLen, msgRaw.Length, padLen);
+ return padded;
+ }
+
+ /// Encrypts a padded proto for a single device, returning a 'to' node or null on failure.
+ private BinaryNode? EncryptForDevice(string deviceJid, byte[] paddedProto, ref bool hasPkMsg)
+ {
+ if (!_signalStore.HasSession(deviceJid))
+ {
+ _logger.LogWarning("No session for {Jid} after bundle fetch, skipping", deviceJid);
+ return null;
+ }
+ try
+ {
+ var (encBytes, isPreKey) = _signalStore.EncryptMessage(deviceJid, paddedProto, _auth);
+ if (isPreKey) hasPkMsg = true;
+ var encNode = new BinaryNode("enc", new Dictionary
+ {
+ ["v"] = "2",
+ ["type"] = isPreKey ? "pkmsg" : "msg",
+ }) { Content = encBytes };
+
+ return new BinaryNode("to", new Dictionary
+ {
+ ["jid"] = deviceJid,
+ }) { Content = new List { encNode } };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to encrypt for {Jid}: {Error}", deviceJid, ex.Message);
+ return null;
+ }
+ }
+
+ // ─── IQ helper ──────────────────────────────────────────────────────────
+
+ private async Task SendIQAsync(BinaryNode iq, CancellationToken ct, int timeoutMs = 15000)
+ {
+ var id = iq.GetAttr("id") ?? GenerateMessageId();
+ if (!iq.Attrs.ContainsKey("id")) iq.Attrs["id"] = id;
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingIqs[id] = tcs;
+
+ _logger.LogInformation("Sending IQ id={Id} xmlns={Xmlns} type={Type}, pending count={Count}",
+ id, iq.GetAttr("xmlns") ?? "?", iq.GetAttr("type") ?? "?", _pendingIqs.Count);
+
+ await SendNodeAsync(iq, ct);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(timeoutMs);
+
+ try
+ {
+ return await tcs.Task.WaitAsync(timeoutCts.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ _pendingIqs.Remove(id);
+ throw new TimeoutException($"IQ {id} timed out after {timeoutMs}ms");
+ }
+ }
+
+ // ─── Fetch pre-key bundles ───────────────────────────────────────────────
+
+ ///
+ /// Sends an encrypt IQ to fetch pre-key bundles for a list of device JIDs.
+ /// Returns a dictionary keyed by device JID.
+ ///
+ private async Task> FetchPreKeyBundlesAsync(
+ IEnumerable deviceJids, CancellationToken ct)
+ {
+ var userNodes = deviceJids.Select(d => new BinaryNode("user", new Dictionary
+ {
+ ["jid"] = d,
+ })).ToList();
+
+ var keyNode = new BinaryNode("key") { Content = userNodes };
+ var iqId = GenerateMessageId();
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["xmlns"] = "encrypt",
+ ["type"] = "get",
+ ["to"] = "@s.whatsapp.net",
+ ["id"] = iqId,
+ }) { Content = new List { keyNode } };
+
+ BinaryNode response;
+ try { response = await SendIQAsync(iq, ct, timeoutMs: 30000); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "FetchPreKeyBundles IQ failed");
+ return new Dictionary();
+ }
+
+ var result = new Dictionary();
+ var listNode = response.FindChild("list") ?? response;
+
+ foreach (var userNode in listNode.GetChildren("user"))
+ {
+ var userJid = userNode.GetAttr("jid") ?? "";
+ if (string.IsNullOrEmpty(userJid)) continue;
+
+ try
+ {
+ var bundle = ParsePreKeyBundle(userNode);
+ result[userJid] = bundle;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to parse pre-key bundle for {Jid}", userJid);
+ }
+ }
+
+ return result;
+ }
+
+ private static PreKeyBundle ParsePreKeyBundle(BinaryNode userNode)
+ {
+ var regNode = userNode.FindChild("registration");
+ var typeNode = userNode.FindChild("type");
+ var idNode = userNode.FindChild("identity");
+ var skeyNode = userNode.FindChild("skey");
+ var otpkNode = userNode.FindChild("key");
+
+ var regBytes = regNode?.Data ?? [];
+ uint regId = regBytes.Length >= 4
+ ? (uint)((regBytes[0] << 24) | (regBytes[1] << 16) | (regBytes[2] << 8) | regBytes[3])
+ : 0;
+
+ var identityKey = idNode?.Data ?? [];
+
+ byte[] spkPub = [];
+ uint spkId = 0;
+ byte[] spkSig = [];
+ if (skeyNode != null)
+ {
+ var skeyIdBytes = skeyNode.FindChild("id")?.Data ?? [];
+ spkId = skeyIdBytes.Length >= 3
+ ? (uint)((skeyIdBytes[0] << 16) | (skeyIdBytes[1] << 8) | skeyIdBytes[2])
+ : 0;
+ spkPub = skeyNode.FindChild("value")?.Data ?? [];
+ spkSig = skeyNode.FindChild("signature")?.Data ?? [];
+ }
+
+ byte[]? otpkPub = null;
+ uint otpkId = 0;
+ if (otpkNode != null)
+ {
+ var otpkIdBytes = otpkNode.FindChild("id")?.Data ?? [];
+ otpkId = otpkIdBytes.Length >= 3
+ ? (uint)((otpkIdBytes[0] << 16) | (otpkIdBytes[1] << 8) | otpkIdBytes[2])
+ : 0;
+ otpkPub = otpkNode.FindChild("value")?.Data;
+ }
+
+ return new PreKeyBundle
+ {
+ TheirIdentityPub = identityKey,
+ TheirSignedPreKeyPub = spkPub,
+ TheirSignedPreKeyId = spkId,
+ TheirSignedPreKeySig = spkSig,
+ TheirOneTimePreKeyPub = otpkPub,
+ TheirOneTimePreKeyId = otpkId,
+ PeerRegistrationId = regId,
+ };
+ }
+
+ // ─── Get device list (USync) ─────────────────────────────────────────────
+
+ ///
+ /// Sends a USync IQ to get all device JIDs for a phone number.
+ /// Returns list of device JIDs like "31633984381:0@s.whatsapp.net".
+ ///
+ private async Task> GetDeviceListAsync(string phoneNumber, CancellationToken ct)
+ {
+ var jid = $"{phoneNumber.TrimStart('+')}@s.whatsapp.net";
+ var sid = GenerateMessageId();
+ var iqId = GenerateMessageId();
+
+ var devicesNode = new BinaryNode("devices", new Dictionary { ["version"] = "2" });
+ var queryNode = new BinaryNode("query") { Content = new List { devicesNode } };
+
+ var userNode = new BinaryNode("user", new Dictionary { ["jid"] = jid });
+ var listNode = new BinaryNode("list") { Content = new List { userNode } };
+
+ var usyncNode = new BinaryNode("usync", new Dictionary
+ {
+ ["context"] = "message",
+ ["mode"] = "query",
+ ["last"] = "true",
+ ["index"] = "0",
+ ["sid"] = sid,
+ }) { Content = new List { queryNode, listNode } };
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "get",
+ ["xmlns"] = "usync",
+ ["id"] = iqId,
+ }) { Content = new List { usyncNode } };
+
+ BinaryNode response;
+ try { response = await SendIQAsync(iq, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "GetDeviceList IQ failed for {Phone}", phoneNumber);
+ return [$"{phoneNumber}:0@s.whatsapp.net"];
+ }
+
+ var deviceJids = new List();
+ WalkForDevices(response, phoneNumber, deviceJids);
+
+ if (deviceJids.Count == 0)
+ deviceJids.Add($"{phoneNumber}:0@s.whatsapp.net");
+
+ return deviceJids;
+ }
+
+ private static void WalkForDevices(BinaryNode node, string phoneNumber, List result)
+ {
+ if (node.Tag == "device")
+ {
+ // USync response has two formats:
+ // 1.
+ // 2. (just the device ID, no full JID)
+ var jid = node.GetAttr("jid");
+ if (!string.IsNullOrEmpty(jid))
+ {
+ result.Add(jid);
+ return;
+ }
+ var id = node.GetAttr("id");
+ if (id != null)
+ {
+ // Device 0 is the primary phone — Baileys encodes as "user@server" (no :0)
+ // Other devices use "user:device@server"
+ if (id == "0")
+ result.Add($"{phoneNumber}@s.whatsapp.net");
+ else
+ result.Add($"{phoneNumber}:{id}@s.whatsapp.net");
+ return;
+ }
+ }
+ foreach (var child in node.Children)
+ WalkForDevices(child, phoneNumber, result);
+ }
+
+ // ─── Low-level send/receive ─────────────────────────────────────────────
+
+ private async Task SendNodeAsync(BinaryNode node, CancellationToken ct)
+ {
+ // Log outbound node so we can compare sent vs received in nodelog/nodes.log
+ try
+ {
+ var logDir = Path.Combine(_options.SessionDirectory, "..", "nodelog");
+ Directory.CreateDirectory(logDir);
+ var logFile = Path.Combine(logDir, "nodes.log");
+ File.AppendAllText(logFile, $"[{DateTime.UtcNow:HH:mm:ss}] SEND: {node}\n");
+ }
+ catch { /* non-fatal */ }
+
+ var encoded = BinaryNodeEncoder.Encode(node);
+ // Prepend flags byte (0x00 = uncompressed) — server strips this on receive
+ // just as we strip it from server frames in StripFlagsAndDecompress().
+ var frameData = new byte[1 + encoded.Length];
+ frameData[0] = 0;
+ encoded.CopyTo(frameData, 1);
+ var encrypted = EncryptFrame(frameData);
+ await _socket.SendFrameAsync(encrypted, ct);
+ }
+
+ private bool _firstHandshakeFrame = true;
+
+ private async Task SendHandshakeMessageAsync(HandshakeMessage msg, CancellationToken ct)
+ {
+ var msgBytes = msg.ToByteArray();
+
+ if (_firstHandshakeFrame)
+ {
+ // Baileys wire format for the FIRST frame:
+ // [WA_PROLOGUE: 4 bytes] [length: 3 bytes BE] [proto payload: N bytes]
+ // The WA prologue is OUTSIDE the 3-byte length framing (unlike subsequent frames).
+ var raw = new byte[WA_PROLOGUE.Length + 3 + msgBytes.Length];
+ WA_PROLOGUE.CopyTo(raw, 0);
+ raw[4] = (byte)(msgBytes.Length >> 16);
+ raw[5] = (byte)(msgBytes.Length >> 8);
+ raw[6] = (byte)(msgBytes.Length);
+ msgBytes.CopyTo(raw, 7);
+ await _socket.SendRawAsync(raw, ct);
+ _firstHandshakeFrame = false;
+ }
+ else
+ {
+ // Subsequent frames: standard [3-byte len][payload]
+ await _socket.SendFrameAsync(msgBytes, ct);
+ }
+ }
+
+ private byte[] EncryptFrame(byte[] data)
+ {
+ if (!_handshakeDone || _sendKey == null)
+ throw new InvalidOperationException("Handshake not complete.");
+ return AesGcmHelper.EncryptWithCounter(_sendKey, _sendCounter++, data);
+ }
+
+ private byte[] DecryptFrame(byte[] data)
+ {
+ if (!_handshakeDone || _recvKey == null)
+ throw new InvalidOperationException("Handshake not complete.");
+ return AesGcmHelper.DecryptWithCounter(_recvKey, _recvCounter++, data);
+ }
+
+ // ─── Helpers ────────────────────────────────────────────────────────────
+
+ private static byte[] StripFlagsAndDecompress(byte[] decrypted)
+ {
+ if (decrypted.Length == 0) return decrypted;
+ var flags = decrypted[0];
+ var data = decrypted[1..];
+
+ if ((flags & 2) != 0)
+ {
+ // WhatsApp uses zlib compression (with 2-byte header), not raw deflate.
+ // Baileys uses Node.js inflateSync() which handles the zlib wrapper.
+ // .NET ZLibStream handles the zlib header correctly.
+ using var input = new MemoryStream(data);
+ using var zlib = new ZLibStream(input, CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ zlib.CopyTo(output);
+ return output.ToArray();
+ }
+
+ return data;
+ }
+
+ // WA web version now comes from WaVersionProvider (live fetch + disk cache + compiled
+ // fallback) — WhatsApp 405-rejects fresh registrations from outdated versions.
+
+ private byte[] BuildClientPayload()
+ {
+ var userAgent = new UserAgent
+ {
+ Platform = 14, // WEB
+ AppVersion = new AppVersion
+ {
+ Primary = WaVersionProvider.Primary,
+ Secondary = WaVersionProvider.Secondary,
+ Tertiary = WaVersionProvider.Tertiary,
+ },
+ Mcc = "000",
+ Mnc = "000",
+ OsVersion = "0.1",
+ Device = "Desktop", // Baileys getUserAgent always uses "Desktop"
+ OsBuildNumber = "0.1",
+ LocaleLanguageIso6391 = "en",
+ LocaleCountryIso31661Alpha2 = "US",
+ };
+
+ if (_auth.IsFresh)
+ {
+ // Fresh registration: include device pairing data so server knows our keys
+ var buildHash = MD5.HashData(System.Text.Encoding.UTF8.GetBytes(WaVersionProvider.VersionString));
+
+ // Registration ID as 4-byte big-endian
+ var eRegid = new byte[4];
+ eRegid[0] = (byte)(_auth.RegistrationId >> 24);
+ eRegid[1] = (byte)(_auth.RegistrationId >> 16);
+ eRegid[2] = (byte)(_auth.RegistrationId >> 8);
+ eRegid[3] = (byte)(_auth.RegistrationId);
+
+ // Signed pre-key ID as 3-byte big-endian
+ var eSkeyId = new byte[3];
+ eSkeyId[0] = (byte)(_auth.SignedPreKeyId >> 16);
+ eSkeyId[1] = (byte)(_auth.SignedPreKeyId >> 8);
+ eSkeyId[2] = (byte)(_auth.SignedPreKeyId);
+
+ var deviceProps = new DevicePropsMessage
+ {
+ // Os="Ubuntu", HistorySyncConfig — set by default
+ PlatformType = 1, // CHROME
+ Version = new AppVersion
+ {
+ Primary = WaVersionProvider.Primary,
+ Secondary = WaVersionProvider.Secondary,
+ Tertiary = WaVersionProvider.Tertiary,
+ },
+ }.ToByteArray();
+
+ return new ClientPayload
+ {
+ Passive = false,
+ Pull = false,
+ ConnectType = 1, // WIFI_UNKNOWN
+ ConnectReason = 1, // USER_ACTIVATED
+ UserAgent = userAgent,
+ WebInfo = new WebInfo { WebSubPlatform = 0 },
+ DevicePairingData = new DevicePairingRegistrationData
{
ERegid = eRegid,
EKeytype = [5], // KEY_BUNDLE_TYPE
@@ -484,34 +2430,1973 @@ private byte[] BuildClientPayload()
BuildHash = buildHash,
DeviceProps = deviceProps,
},
- }.ToByteArray();
+ }.ToByteArray();
+ }
+ else
+ {
+ // Session restore (login)
+ // Me.Id format: "31633984381:20@s.whatsapp.net" — extract phone and device number
+ var rawId = _auth.Me?.Id.Split('@')[0] ?? "0"; // "31633984381:20"
+ var parts = rawId.Split(':');
+ ulong.TryParse(parts[0], out var userId); // "31633984381" → 31633984381
+ uint.TryParse(parts.Length > 1 ? parts[1] : "0", out var deviceId); // "20" → 20
+ return new ClientPayload
+ {
+ Username = userId,
+ Device = deviceId, // CRITICAL: server needs device number to route the session
+ Passive = true,
+ Pull = true,
+ ConnectType = 1,
+ ConnectReason = 1,
+ UserAgent = userAgent,
+ WebInfo = new WebInfo { WebSubPlatform = 0 },
+ }.ToByteArray();
+ }
+ }
+
+ ///
+ /// Uploads one-time pre-keys and the signed pre-key to WhatsApp server
+ /// so other devices can initiate Signal sessions with us.
+ /// Called once after successful authentication.
+ ///
+ private async Task UploadPreKeysAsync(CancellationToken ct)
+ {
+ const int BatchSize = 30; // upload 30 one-time pre-keys at a time
+
+ // Generate fresh pre-keys if we've run out — private keys must be available for decryption
+ if (_auth.PreKeys.Count == 0)
+ {
+ _logger.LogWarning("Pre-key pool exhausted — generating 100 new pre-keys.");
+ // Start IDs from a high offset to avoid collisions with any still-live server-side keys
+ uint startId = 1001;
+ for (uint i = 0; i < 100; i++)
+ {
+ var (priv, pub) = Crypto.Curve25519Helper.GenerateKeyPair();
+ _auth.PreKeys.Add(new Auth.PreKey { Id = startId + i, Private = priv, Public = pub });
+ }
+ }
+
+ var keysToUpload = _auth.PreKeys.Take(BatchSize).ToList();
+
+ if (keysToUpload.Count == 0)
+ {
+ _logger.LogWarning("No pre-keys available to upload — others cannot initiate Signal sessions.");
+ return;
+ }
+
+ // Baileys format: raw 32-byte keys (no 0x05 prefix) — the "type=[0x05]" node indicates key type
+ static byte[] Be3(uint v) => [(byte)(v >> 16), (byte)(v >> 8), (byte)v];
+ static byte[] Be4(uint v) => [(byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v];
+
+ // Build key list nodes — value = raw 32-byte public key (no prefix)
+ var keyNodes = keysToUpload.Select(k =>
+ new BinaryNode("key", null, new List
+ {
+ new("id", null, Be3(k.Id)),
+ new("value", null, k.Public), // raw 32 bytes
+ })
+ ).ToList();
+
+ // Signed pre-key node
+ var skeyNode = new BinaryNode("skey", null, new List
+ {
+ new("id", null, Be3(_auth.SignedPreKeyId)),
+ new("value", null, _auth.SignedPreKeyPublic), // raw 32 bytes
+ new("signature", null, _auth.SignedPreKeySignature),
+ });
+
+ var content = new List
+ {
+ new("registration", null, Be4(_auth.RegistrationId)),
+ new("type", null, new byte[] { 5 }), // KEY_BUNDLE_TYPE — indicates Curve25519
+ new("identity", null, _auth.SignedIdentityKeyPublic), // raw 32 bytes
+ new("list", null, keyNodes),
+ skeyNode,
+ };
+
+ var iqNode = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["xmlns"] = "encrypt",
+ ["type"] = "set",
+ ["to"] = "@s.whatsapp.net",
+ })
+ {
+ Content = content,
+ };
+
+ try
+ {
+ var result = await SendIQAsync(iqNode, ct);
+ _logger.LogInformation("Pre-keys uploaded successfully ({Count} one-time keys).", keysToUpload.Count);
+
+ // NOTE: Do NOT remove uploaded pre-keys from the local pool.
+ // Their private keys must remain in _auth.PreKeys so that InitIncomingSession
+ // can look them up by ID when a pkmsg arrives. The keys are removed there,
+ // after the session is actually established and the private key consumed.
+ // Persist updated auth state in case we generated new pre-keys above.
+ Authenticated?.Invoke(this, _auth);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to upload pre-keys.");
+ }
+ }
+
+ private static string GenerateMessageId()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(8);
+ return BitConverter.ToString(bytes).Replace("-", "").ToUpper();
+ }
+
+ // ─── App state sync: contact fetching ───────────────────────────────────
+
+ ///
+ /// Fetches a 32-byte app state key from the server by keyId.
+ ///
+ private async Task FetchAppStateSyncKeyAsync(byte[] keyId, CancellationToken ct)
+ {
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "get",
+ ["xmlns"] = "w:sync:app:state:k",
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("key", null, new List
+ {
+ new BinaryNode("id", null, keyId),
+ }),
+ },
+ };
+
+ var result = await SendIQAsync(iq, ct, timeoutMs: 15000);
+
+ // Navigate result → key → key-data
+ var keyNode = result.FindChild("key") ?? result;
+ var keyDataNode = keyNode.FindChild("key-data");
+ if (keyDataNode?.Data is { Length: 32 } appKey)
+ return appKey;
+
+ // Fallback: walk children for a 32-byte data blob
+ foreach (var child in result.Children)
+ {
+ if (child.Data is { Length: 32 } d) return d;
+ foreach (var grandchild in child.Children)
+ if (grandchild.Data is { Length: 32 } d2) return d2;
+ }
+
+ throw new InvalidOperationException($"App state sync key response did not contain a 32-byte key (tag={result.Tag})");
+ }
+
+ ///
+ /// Fetches contacts from WhatsApp via app state sync of the "contact" collection.
+ /// Returns (JID, Name) pairs for all contacts found in the snapshot.
+ ///
+ private BinaryNode BuildAppStateSyncIQ(string collectionName, int version, bool returnSnapshot)
+ {
+ var attrs = new Dictionary
+ {
+ ["name"] = collectionName,
+ ["version"] = version.ToString(),
+ };
+ if (returnSnapshot) attrs["return_snapshot"] = "true";
+
+ return new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "set",
+ ["xmlns"] = "w:app:state:sync",
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("sync", null, new List
+ {
+ new BinaryNode("collection", attrs),
+ }),
+ },
+ };
+ }
+
+ ///
+ /// Returns contacts from multiple sources:
+ /// Push names collected from incoming messages (participant_pn, sender_lid, notify attributes).
+ /// USync contact queries consistently return empty results for companion devices.
+ ///
+ public Task> FetchContactsAsync(CancellationToken ct)
+ {
+ _logger.LogInformation("FetchContactsAsync: starting (pushNames={Names}, lidToPhone={Lids})",
+ _pushNames.Count, _lidToPhone.Count);
+
+ var contacts = new List<(string Jid, string Name)>();
+ var seenJids = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var kv in _pushNames)
+ {
+ // Resolve LID JIDs to phone JIDs using the lidToPhone mapping
+ var jid = kv.Key.EndsWith("@lid") && _lidToPhone.TryGetValue(kv.Key, out var ph) ? ph : kv.Key;
+ // Only include contacts with resolved phone JIDs (skip unresolvable LIDs and non-contacts)
+ if (jid.EndsWith("@s.whatsapp.net") && seenJids.Add(jid) && !string.IsNullOrEmpty(kv.Value))
+ contacts.Add((jid, kv.Value));
+ }
+
+ _logger.LogInformation("FetchContactsAsync: returning {Count} contacts", contacts.Count);
+ return Task.FromResult(contacts);
+ }
+
+ ///
+ /// Uses USync IQ (which reliably returns results for companion devices) to fetch
+ /// contact names for a set of JIDs. Handles both @lid and @s.whatsapp.net JIDs.
+ ///
+ private async Task> FetchContactsViaUsyncAsync(
+ IEnumerable jids, CancellationToken ct)
+ {
+ var jidList = jids.ToList();
+ if (jidList.Count == 0) return [];
+
+ // Build user nodes: phone JIDs use jid= attribute, LID JIDs use a child node
+ var userNodes = jidList.Select(j =>
+ {
+ if (j.EndsWith("@lid"))
+ {
+ // LID JIDs must be sent as ...
+ return new BinaryNode("user")
+ {
+ Content = new List
+ {
+ new BinaryNode("lid") { Content = j },
+ }
+ };
+ }
+ // Phone JIDs:
+ return new BinaryNode("user", new Dictionary { ["jid"] = j });
+ }).ToList();
+
+ var usyncNode = new BinaryNode("usync", new Dictionary
+ {
+ ["context"] = "interactive",
+ ["mode"] = "query",
+ ["last"] = "true",
+ ["index"] = "0",
+ ["sid"] = GenerateMessageId(),
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("query") { Content = new List { new BinaryNode("contact") } },
+ new BinaryNode("list") { Content = userNodes },
+ },
+ };
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "@s.whatsapp.net",
+ ["type"] = "get",
+ ["xmlns"] = "usync",
+ ["id"] = GenerateMessageId(),
+ }) { Content = new List { usyncNode } };
+
+ BinaryNode result;
+ try { result = await SendIQAsync(iq, ct, timeoutMs: 15000); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "FetchContactsViaUsyncAsync: IQ failed");
+ return [];
+ }
+
+ // Parse response: name...
+ var contacts = new List<(string Jid, string Name)>();
+ WalkUsyncForContacts(result, contacts);
+ _logger.LogInformation("FetchContactsViaUsyncAsync: parsed {Count} contacts from USync response", contacts.Count);
+ return contacts;
+ }
+
+ private void WalkUsyncForContacts(BinaryNode node, List<(string Jid, string Name)> result)
+ {
+ if (node.Tag == "user")
+ {
+ var jid = node.GetAttr("jid") ?? "";
+ var lid = node.GetAttr("lid") ?? "";
+
+ // Cache LID → phone JID mapping from USync response
+ if (!string.IsNullOrEmpty(lid) && !string.IsNullOrEmpty(jid))
+ _lidToPhone[lid] = jid;
+
+ // Look for child with push name or status
+ var contactNode = node.FindChild("contact");
+ var name = contactNode?.GetAttr("name")
+ ?? contactNode?.Text
+ ?? node.GetAttr("name");
+
+ if (!string.IsNullOrEmpty(jid))
+ {
+ // Use phone JID, not LID
+ var displayJid = jid.EndsWith("@lid") && _lidToPhone.TryGetValue(jid, out var ph) ? ph : jid;
+ var displayName = name ?? (displayJid.Contains('@') ? displayJid.Split('@')[0] : displayJid);
+ result.Add((displayJid, displayName));
+ }
+ return;
+ }
+ foreach (var child in node.Children)
+ WalkUsyncForContacts(child, result);
+ }
+
+ // ─── Presence ──────────────────────────────────────────────────────────
+
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _presenceCache = new();
+
+ private void HandlePresenceNode(BinaryNode node)
+ {
+ var jid = node.GetAttr("from") ?? "";
+ var type = node.GetAttr("type") ?? "available";
+ var status = type == "unavailable" ? "unavailable" : "available";
+
+ var composing = node.FindChild("composing");
+ var recording = node.FindChild("recording");
+ if (composing != null) status = "composing";
+ if (recording != null) status = "recording";
+
+ _presenceCache[jid] = new PresenceInfo(jid, status, DateTime.UtcNow);
+ _logger.LogDebug("Presence: {Jid} → {Status}", jid, status);
+ }
+
+ public async Task SubscribePresenceAsync(string jid, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var node = new BinaryNode("presence", new Dictionary
+ {
+ ["type"] = "subscribe",
+ ["to"] = normalizedJid,
+ });
+ await SendNodeAsync(node, ct);
+ }
+
+ public PresenceInfo? GetPresence(string jid)
+ => _presenceCache.TryGetValue(jid, out var p) ? p : null;
+
+ // ─── Profile picture ────────────────────────────────────────────────────
+
+ public async Task FetchProfilePictureAsync(string jid, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = normalizedJid,
+ ["type"] = "get",
+ ["xmlns"] = "w:profile:pic",
+ ["id"] = GenerateMessageId(),
+ })
+ {
+ Content = new List
+ {
+ // "query=url" tells WhatsApp to return the CDN URL instead of the raw image
+ new BinaryNode("picture", new Dictionary { ["type"] = "image", ["query"] = "url" })
+ }
+ };
+
+ try
+ {
+ var result = await SendIQAsync(iq, ct, timeoutMs: 15000);
+ _logger.LogInformation("Profile pic response: tag={Tag} type={Type}", result.Tag, result.GetAttr("type") ?? "?");
+ // Result may have a child, or a direct url attr
+ var picNode = result.FindChild("picture");
+ var url = picNode?.GetAttr("url") ?? result.GetAttr("url");
+ _logger.LogInformation("Profile pic url={Url}", url ?? "(none)");
+ return url;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to fetch profile picture for {Jid}", jid);
+ return null;
+ }
+ }
+
+ // ─── Read receipts ──────────────────────────────────────────────────────
+
+ public async Task SendReadReceiptAsync(string jid, string messageId, long timestamp, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var receipt = new BinaryNode("receipt", new Dictionary
+ {
+ ["id"] = messageId,
+ ["to"] = normalizedJid,
+ ["type"] = "read",
+ ["t"] = timestamp.ToString(),
+ });
+ await SendNodeAsync(receipt, ct);
+ _logger.LogInformation("Sent read receipt for message {MsgId} to {Jid}", messageId, normalizedJid);
+ }
+
+ // ─── Chats (from thread_metadata + message history) ─────────────────────
+
+ ///
+ /// Returns the list of active chats. Primary source is thread_metadata from
+ /// the ib node that WhatsApp sends immediately after authentication.
+ /// Falls back to message history if thread_metadata is empty.
+ ///
+ public Task> FetchChatsAsync(CancellationToken ct)
+ {
+ var chats = new List<(string Jid, string Name, bool Archived, bool Pinned)>();
+
+ // Track seen JIDs to deduplicate: a chat may appear as both LID and phone JID in thread_metadata
+ var seenJids = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Use thread_metadata as primary source (populated immediately at startup)
+ if (_threadMetadata.Count > 0)
+ {
+ _logger.LogInformation("FetchChatsAsync: returning {Count} chats from thread_metadata", _threadMetadata.Count);
+
+ foreach (var kv in _threadMetadata.OrderByDescending(x => x.Value))
+ {
+ var rawJid = kv.Key;
+
+ // Resolve LID to phone JID if possible
+ string jid;
+ if (rawJid.EndsWith("@lid") && _lidToPhone.TryGetValue(rawJid, out var resolvedJid))
+ jid = resolvedJid;
+ else
+ jid = rawJid;
+
+ // Deduplicate: skip if we already emitted this resolved JID
+ if (!seenJids.Add(jid)) continue;
+
+ // Get display name from push names, then JID
+ string name;
+ if (_pushNames.TryGetValue(rawJid, out var pn) || _pushNames.TryGetValue(jid, out pn))
+ name = pn;
+ else
+ name = jid.Split('@')[0].Split(':')[0];
+
+ chats.Add((jid, name, false, false));
+ }
+ }
+ else
+ {
+ // Fallback: build from push names cache (populated by received messages)
+ _logger.LogInformation("FetchChatsAsync: no thread_metadata, using pushNames cache ({Count} entries)", _pushNames.Count);
+ foreach (var kv in _pushNames)
+ {
+ var jid = kv.Key.EndsWith("@lid") && _lidToPhone.TryGetValue(kv.Key, out var ph) ? ph : kv.Key;
+ if (seenJids.Add(jid))
+ chats.Add((jid, kv.Value, false, false));
+ }
+ }
+
+ _logger.LogInformation("FetchChatsAsync: returning {Count} chats", chats.Count);
+ return Task.FromResult(chats);
+ }
+
+ /// Returns internal cache state for debugging.
+ public object GetCacheDebugInfo() => new
+ {
+ threadMetadataCount = _threadMetadata.Count,
+ threadMetadata = _threadMetadata.OrderByDescending(x => x.Value)
+ .Take(20)
+ .Select(kv => new { jid = kv.Key, t = kv.Value })
+ .ToList(),
+ lidToPhoneCount = _lidToPhone.Count,
+ lidToPhone = _lidToPhone.Take(10)
+ .Select(kv => new { lid = kv.Key, phone = kv.Value })
+ .ToList(),
+ pushNamesCount = _pushNames.Count,
+ pushNames = _pushNames.Take(10)
+ .Select(kv => new { jid = kv.Key, name = kv.Value })
+ .ToList(),
+ serverSyncVersions = _serverSyncVersions.ToDictionary(kv => kv.Key, kv => kv.Value),
+ };
+
+ // ─── Message history ─────────────────────────────────────────────────────
+
+ ///
+ /// Attempts to fetch message history for a JID using a w:msg sync IQ.
+ /// Returns an empty list if the server does not respond (companion devices may not support this).
+ /// Note: in-memory message cache is used as primary source; this IQ is a best-effort supplement.
+ ///
+ ///
+ /// Requests an ON_DEMAND history sync from the phone by sending a
+ /// peerDataOperationRequestMessage to our own JID.
+ /// The phone responds by pushing a HISTORY_SYNC_NOTIFICATION (syncType 5 = ON_DEMAND)
+ /// which our receive loop handles, firing HistoryMessageReceived for each past message.
+ /// Returns the messages collected from that push notification (or empty if the phone
+ /// does not respond within the timeout).
+ ///
+ public async Task> RequestOnDemandHistorySyncAsync(string chatJid, int count, CancellationToken ct)
+ {
+ var normalizedJid = chatJid.Contains('@') ? chatJid : $"{chatJid}@s.whatsapp.net";
+
+ var myJid = _auth.Me?.Id;
+ if (myJid == null)
+ {
+ // Fail-safe: never throw out of on-demand history sync. It is an optional,
+ // best-effort request; if we cannot make it, the caller simply gets no extra
+ // history and all normal paths (automatic history sync, offline queue, live
+ // delivery) keep working untouched.
+ _logger.LogWarning("OnDemandHistorySync: not authenticated — skipping (normal delivery unaffected)");
+ return new List();
+ }
+
+ // If given a LID, resolve to phone JID for the peerDataOperationRequestMessage
+ // (phone stores chats by phone JID internally, not LID)
+ string requestJid = normalizedJid;
+ if (normalizedJid.EndsWith("@lid"))
+ {
+ // Try to resolve LID → phone JID via our cache
+ if (_lidToPhone.TryGetValue(normalizedJid, out var phoneJid))
+ {
+ _logger.LogInformation("OnDemandHistorySync: resolved LID {Lid} → {Phone} for request", normalizedJid, phoneJid);
+ requestJid = phoneJid;
+ }
+ else
+ {
+ // Strip LID number and try as phone number
+ var lidNum = normalizedJid.Split('@')[0];
+ _logger.LogWarning("OnDemandHistorySync: no phone mapping for LID {Lid} — will try phone JID too", normalizedJid);
+ }
+ }
+
+ // Collect messages for the target JID as they arrive (match both phone and LID variants)
+ var collected = new List();
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ EventHandler onMsg = (_, msg) =>
+ {
+ // Exception-safe: a malformed history message must never throw into the
+ // HistoryMessageReceived event dispatch (which also feeds normal history sync).
+ try
+ {
+ var remoteBase = normalizedJid.Split('@')[0];
+ var requestBase = requestJid.Split('@')[0];
+ if (msg.RemoteJid == normalizedJid ||
+ msg.RemoteJid == requestJid ||
+ msg.From == normalizedJid ||
+ msg.From == requestJid ||
+ (msg.RemoteJid?.Split('@')[0] == remoteBase) ||
+ (msg.RemoteJid?.Split('@')[0] == requestBase))
+ {
+ lock (collected) { collected.Add(msg); }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "OnDemandHistorySync: collector handler error (ignored)");
+ }
+ };
+
+ EventHandler onComplete = (_, n) =>
+ {
+ _logger.LogInformation("OnDemandHistorySync: HistorySyncCompleted fired ({N} msgs)", n);
+ tcs.TrySetResult(n);
+ };
+
+ HistoryMessageReceived += onMsg;
+ HistorySyncCompleted += onComplete;
+
+ try
+ {
+ _logger.LogInformation("OnDemandHistorySync: sending peerDataOperationRequestMessage for {ChatJid} (requestJid={ReqJid})", normalizedJid, requestJid);
+ await SendPeerDataOperationRequestAsync(myJid, requestJid, count, ct);
+ // If LID and request JID differ, also try with the original LID (phone may accept either)
+ if (requestJid != normalizedJid)
+ {
+ await Task.Delay(1000, ct);
+ await SendPeerDataOperationRequestAsync(myJid, normalizedJid, count, ct);
+ }
+
+ // Wait up to 35 seconds for the phone to push the history sync notification
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
+ using (linked.Token.Register(() => tcs.TrySetCanceled()))
+ await tcs.Task.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("OnDemandHistorySync: timed out / cancelled waiting for push notification");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "OnDemandHistorySync: error");
+ }
+ finally
+ {
+ HistoryMessageReceived -= onMsg;
+ HistorySyncCompleted -= onComplete;
+ }
+
+ lock (collected) { return [.. collected]; }
+ }
+
+ private async Task SendPeerDataOperationRequestAsync(string myJid, string targetChatJid, int count, CancellationToken ct)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+
+ // Get all our own devices
+ List myDevices;
+ try { myDevices = await GetDeviceListAsync(myPhone, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "OnDemandHistorySync: failed to get own device list");
+ myDevices = [$"{myPhone}:0@s.whatsapp.net"];
+ }
+
+ _logger.LogInformation("OnDemandHistorySync: encrypting for {Count} own devices: {Jids}",
+ myDevices.Count, string.Join(", ", myDevices));
+
+ // Fetch pre-key bundles for devices without sessions
+ var needBundles = myDevices.Where(d => !_signalStore.HasSession(d)).ToList();
+ if (needBundles.Count > 0)
+ {
+ try
+ {
+ var bundles = await FetchPreKeyBundlesAsync(needBundles, ct);
+ foreach (var (deviceJid, bundle) in bundles)
+ try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); }
+ catch (Exception ex2) { _logger.LogWarning(ex2, "OnDemandHistorySync: session init failed for {Jid}", deviceJid); }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "OnDemandHistorySync: failed to fetch bundles"); }
+ }
+
+ // Build the PeerDataOperationRequestMessage proto
+ var msg = new WAMessage
+ {
+ PeerDataOperation = new Proto.PeerDataOperationRequestMessage
+ {
+ RequestType = Proto.PeerDataOperationRequestMessage.TYPE_HISTORY_SYNC_ON_DEMAND,
+ ChatJid = targetChatJid,
+ OnDemandMsgCount = count,
+ }
+ };
+ var protoBytes = PadMessage(msg.ToByteArrayWithPeerDataOperation());
+
+ // Encrypt for each of our own devices
+ var msgId = GenerateMessageId();
+ var hasPkMsg = false;
+ var toNodes = new List();
+ foreach (var deviceJid in myDevices)
+ {
+ var node = EncryptForDevice(deviceJid, protoBytes, ref hasPkMsg);
+ if (node != null) toNodes.Add(node);
+ }
+
+ if (toNodes.Count == 0)
+ {
+ _logger.LogWarning("OnDemandHistorySync: no devices encrypted to — cannot send request");
+ return;
+ }
+
+ var participantsNode = new BinaryNode("participants") { Content = toNodes };
+ var contentNodes = new List { participantsNode };
+
+ if (hasPkMsg && _auth.Account != null)
+ {
+ var accountProto = ADVSignedDeviceIdentity.ParseFrom(_auth.Account);
+ contentNodes.Add(new BinaryNode("device-identity") { Content = accountProto.ToByteArray() });
+ }
+
+ var msgNode = new BinaryNode("message", new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "text",
+ ["to"] = $"{myPhone}@s.whatsapp.net",
+ }) { Content = contentNodes };
+
+ // Track this PDO message so that if the phone sends a retry receipt we can resend
+ _sentPdoMsgIds.Add(msgId);
+ _lastPdoRequest = (targetChatJid, null, false, 0, count);
+
+ await SendNodeAsync(msgNode, ct);
+ _logger.LogInformation("OnDemandHistorySync: sent peerDataOperationRequestMessage (msgId={Id}) to self", msgId);
+ }
+
+ public async Task> FetchMessageHistoryAsync(string jid, int count, CancellationToken ct)
+ {
+ // Normalize JID
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid}@s.whatsapp.net";
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = "s.whatsapp.net",
+ ["type"] = "set",
+ ["xmlns"] = "w:msg",
+ ["id"] = GenerateMessageId(),
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("sync")
+ {
+ Content = new List
+ {
+ new BinaryNode("conversation", new Dictionary
+ {
+ ["jid"] = normalizedJid,
+ ["t"] = "0",
+ ["count"] = count.ToString(),
+ }),
+ },
+ },
+ },
+ };
+
+ try
+ {
+ _logger.LogInformation("FetchMessageHistoryAsync: sending w:msg sync IQ for {Jid}", normalizedJid);
+ var result = await SendIQAsync(iq, ct, timeoutMs: 20000);
+ _logger.LogInformation("FetchMessageHistoryAsync: w:msg result tag={Tag}", result.Tag);
+
+ // Parse any message nodes in the result
+ var messages = new List();
+ var msgNodes = new List();
+ CollectNodes(result, "message", msgNodes);
+ foreach (var msgNode in msgNodes)
+ {
+ var text = msgNode.FindChild("body")?.Content as string
+ ?? msgNode.GetAttr("body");
+ if (string.IsNullOrEmpty(text)) continue;
+
+ var from = msgNode.GetAttr("from") ?? "";
+ long.TryParse(msgNode.GetAttr("t"), out var ts);
+ var fromMe = from == _auth.Me?.Id?.Split(':')[0] + "@s.whatsapp.net";
+ messages.Add(new IncomingMessage
+ {
+ Id = msgNode.GetAttr("id") ?? "",
+ From = from,
+ RemoteJid = normalizedJid,
+ Text = text,
+ FromMe = fromMe,
+ Timestamp = ts,
+ });
+ }
+
+ return messages;
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogInformation("FetchMessageHistoryAsync: w:msg IQ timed out (companion devices may not support history sync via IQ)");
+ return new List();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "FetchMessageHistoryAsync: w:msg IQ failed for {Jid}", normalizedJid);
+ return new List();
+ }
+ }
+
+ private static void CollectNodes(BinaryNode node, string tag, List result)
+ {
+ if (node.Tag == tag) result.Add(node);
+ foreach (var child in node.Children)
+ CollectNodes(child, tag, result);
+ }
+
+ // ─── History Sync (HistorySyncNotification → CDN download → proto parse) ─
+
+ private static readonly HttpClient _httpClient = new(new HttpClientHandler
+ {
+ AutomaticDecompression = System.Net.DecompressionMethods.None, // handle ourselves
+ });
+
+ ///
+ /// Handles a HistorySyncNotification message from the phone.
+ /// Downloads the encrypted blob from WhatsApp CDN, decrypts it, parses the
+ /// HistorySync protobuf, and fires HistoryMessageReceived for each message and HistorySyncCompleted at the end.
+ ///
+ private async Task HandleHistorySyncAsync(
+ Dawa.Proto.HistorySyncNotification notification,
+ string msgId, string from, long timestamp, CancellationToken ct)
+ {
+ _logger.LogInformation(
+ "HistorySync: type={Type} chunkOrder={Chunk} directPath={Path} fileLen={Len}",
+ notification.SyncType, notification.Progress, notification.DirectPath, notification.FileLength);
+
+ try
+ {
+ // ── 1. Download encrypted blob from WhatsApp CDN ───────────────────
+ if (string.IsNullOrEmpty(notification.DirectPath))
+ {
+ _logger.LogWarning("HistorySync: no directPath — cannot download blob");
+ _ = SendAckAsync(msgId, from, timestamp);
+ return;
+ }
+
+ var cdnUrl = "https://mmg.whatsapp.net" + notification.DirectPath;
+ byte[] encryptedBlob;
+ try
+ {
+ encryptedBlob = await _httpClient.GetByteArrayAsync(cdnUrl, ct);
+ _logger.LogInformation("HistorySync: downloaded {Bytes} bytes from CDN", encryptedBlob.Length);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: CDN download failed for {Url}", cdnUrl);
+ _ = SendAckAsync(msgId, from, timestamp);
+ return;
+ }
+
+ // ── 2. Decrypt: HKDF-expand mediaKey → IV(16) + aesKey(32) + macKey(32) ─
+ // Info string for history sync is "WhatsApp History Keys"
+ // Expand mediaKey using HKDF-SHA256 with WhatsApp's standard media key derivation.
+ // Info: "WhatsApp History Keys", salt: 32 zero bytes, output: 112 bytes.
+ // Layout (from Baileys): IV=0..15, AES=16..47, MAC=48..79
+ var expanded = Dawa.Crypto.DawaHKDF.DeriveKey(
+ notification.MediaKey,
+ salt: new byte[32], // zero salt
+ info: System.Text.Encoding.UTF8.GetBytes("WhatsApp History Keys"),
+ outputLength: 80);
+
+ var iv = expanded[0..16]; // bytes 0..15
+ var aesKey = expanded[16..48]; // bytes 16..47
+ // macKey is expanded[48..80] — we trust the download, skip MAC verification for now
+
+ // Strip trailing 10-byte HMAC
+ var ciphertext = encryptedBlob[..^10];
+
+ byte[] decrypted;
+ try
+ {
+ using var aes = System.Security.Cryptography.Aes.Create();
+ aes.Key = aesKey;
+ aes.IV = iv;
+ aes.Mode = System.Security.Cryptography.CipherMode.CBC;
+ aes.Padding = System.Security.Cryptography.PaddingMode.PKCS7;
+ using var dec = aes.CreateDecryptor();
+ decrypted = dec.TransformFinalBlock(ciphertext, 0, ciphertext.Length);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: AES decryption failed");
+ _ = SendAckAsync(msgId, from, timestamp);
+ return;
+ }
+
+ // ── 3. Gunzip the decrypted bytes ─────────────────────────────────
+ byte[] protoBytes;
+ try
+ {
+ using var inStream = new System.IO.MemoryStream(decrypted);
+ using var gzip = new System.IO.Compression.GZipStream(inStream, System.IO.Compression.CompressionMode.Decompress);
+ using var outStream = new System.IO.MemoryStream();
+ await gzip.CopyToAsync(outStream, ct);
+ protoBytes = outStream.ToArray();
+ _logger.LogInformation("HistorySync: decompressed to {Bytes} bytes", protoBytes.Length);
+ }
+ catch
+ {
+ // Not gzip — use raw bytes
+ protoBytes = decrypted;
+ _logger.LogInformation("HistorySync: not gzip-compressed, using raw {Bytes} bytes", protoBytes.Length);
+ }
+
+ // ── 4. Parse HistorySync protobuf ──────────────────────────────────
+ Dawa.Proto.HistorySync historySync;
+ try
+ {
+ historySync = Dawa.Proto.HistorySync.Decode(protoBytes);
+ _logger.LogInformation("HistorySync: parsed {ConvCount} conversations, {NameCount} push names",
+ historySync.Conversations.Count, historySync.PushNames.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: failed to parse HistorySync proto ({Bytes} bytes)", protoBytes.Length);
+ _ = SendAckAsync(msgId, from, timestamp);
+ return;
+ }
+
+ // ── 5. Store push names from history ─────────────────────────────────
+ foreach (var pn in historySync.PushNames)
+ {
+ if (!string.IsNullOrEmpty(pn.Id) && !string.IsNullOrEmpty(pn.PushName))
+ _pushNames.TryAdd(pn.Id, pn.PushName);
+ }
+
+ // ── 6. Fire HistoryMessageReceived for each message ───────────────────
+ var totalMessages = 0;
+ foreach (var conv in historySync.Conversations)
+ {
+ var chatJid = conv.Id;
+ if (string.IsNullOrEmpty(chatJid)) continue;
+
+ foreach (var hm in conv.Messages)
+ {
+ var wmi = hm.Message;
+ if (wmi == null) continue;
+ var key = wmi.Key;
+ if (key == null) continue;
+
+ var text = wmi.MessageBody?.EffectiveText;
+ if (string.IsNullOrEmpty(text)) continue;
+
+ var msgFromMe = key.FromMe;
+ var msgFrom = msgFromMe
+ ? (_auth.Me?.Id ?? chatJid)
+ : (!string.IsNullOrEmpty(key.Participant) ? key.Participant : chatJid);
+
+ HistoryMessageReceived?.Invoke(this, new IncomingMessage
+ {
+ Id = key.Id,
+ From = msgFrom,
+ RemoteJid = key.RemoteJid.Length > 0 ? key.RemoteJid : chatJid,
+ Participant = key.Participant.Length > 0 ? key.Participant : null,
+ Text = text,
+ FromMe = msgFromMe,
+ Timestamp = (long)wmi.Timestamp,
+ PushName = wmi.PushName,
+ });
+ totalMessages++;
+ }
+
+ // Update thread metadata with latest message timestamp
+ if (conv.Messages.Count > 0)
+ {
+ var latest = conv.Messages
+ .Where(m => m.Message != null)
+ .Max(m => (long)m.Message!.Timestamp);
+ _threadMetadata.TryAdd(chatJid, latest);
+ }
+ }
+
+ _logger.LogInformation("HistorySync: fired {Total} HistoryMessageReceived events across {Convs} conversations",
+ totalMessages, historySync.Conversations.Count);
+
+ // Signal completion so callers can persist the full sync
+ HistorySyncCompleted?.Invoke(this, totalMessages);
+
+ SaveCacheToDisk();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: unexpected error");
+ }
+ finally
+ {
+ _ = SendAckAsync(msgId, from, timestamp);
+ }
+ }
+
+ // ─── Groups ──────────────────────────────────────────────────────────────
+
+ ///
+ /// Returns the list of group JIDs from thread_metadata (those ending in @g.us).
+ ///
+ public List GetGroupJids()
+ => _threadMetadata.Keys.Where(j => j.EndsWith("@g.us")).ToList();
+
+ ///
+ /// Fetches group metadata (name, participants) for a specific group JID.
+ /// Sends an IQ to the group's server and parses the response.
+ ///
+ public async Task FetchGroupMetadataAsync(string groupJid, CancellationToken ct)
+ {
+ if (!groupJid.EndsWith("@g.us"))
+ throw new ArgumentException("Not a group JID", nameof(groupJid));
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["to"] = groupJid,
+ ["type"] = "get",
+ ["xmlns"] = "w:g2",
+ ["id"] = GenerateMessageId(),
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("query", new Dictionary { ["request"] = "interactive" }),
+ },
+ };
+
+ BinaryNode result;
+ try { result = await SendIQAsync(iq, ct, timeoutMs: 15000); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "FetchGroupMetadataAsync: IQ failed for {Group}", groupJid);
+ return null;
+ }
+
+ // Response:
+ // ...
+ var groupNode = FindDeep(result, "group");
+ if (groupNode == null)
+ {
+ _logger.LogWarning("FetchGroupMetadataAsync: no node in response for {Group}", groupJid);
+ return null;
+ }
+
+ var subject = groupNode.GetAttr("subject") ?? "";
+ var creator = groupNode.GetAttr("creator") ?? "";
+ var creation = groupNode.GetAttr("creation") ?? "0";
+ long.TryParse(creation, out var creationTs);
+
+ // Participants — group IQ includes phone_number attribute for LID→phone resolution
+ var cacheUpdated = false;
+ var participants = groupNode.GetChildren("participant").Select(p =>
+ {
+ var lidJid = p.GetAttr("jid") ?? "";
+ var phoneJid = p.GetAttr("phone_number"); // e.g. "254708713947@s.whatsapp.net"
+ var pType = p.GetAttr("type") ?? "member";
+
+ // Populate LID→phone cache from the group response
+ if (!string.IsNullOrEmpty(lidJid) && !string.IsNullOrEmpty(phoneJid)
+ && !_lidToPhone.ContainsKey(lidJid))
+ {
+ _lidToPhone[lidJid] = phoneJid;
+ cacheUpdated = true;
+ }
+
+ // Resolve display JID to phone number JID where possible
+ var displayJid = (!string.IsNullOrEmpty(phoneJid)) ? phoneJid : lidJid;
+
+ return new GroupParticipant(
+ Jid: displayJid,
+ LidJid: lidJid,
+ Type: pType
+ );
+ }).Where(p => !string.IsNullOrEmpty(p.Jid)).ToList();
+
+ if (cacheUpdated) SaveCacheToDisk();
+
+ return new GroupMetadata(
+ Jid: groupJid,
+ Subject: subject,
+ Creator: creator,
+ CreationTimestamp: creationTs,
+ Participants: participants
+ );
+ }
+
+ private static BinaryNode? FindDeep(BinaryNode node, string tag)
+ {
+ if (node.Tag == tag) return node;
+ foreach (var child in node.Children)
+ {
+ var found = FindDeep(child, tag);
+ if (found != null) return found;
+ }
+ return null;
+ }
+
+ ///
+ /// Resolves a LID JID to a phone JID using a usync IQ query.
+ /// If already cached, returns the cached value immediately.
+ /// Otherwise sends a usync query to WhatsApp and caches the result.
+ /// Returns null if the LID cannot be resolved.
+ ///
+ public async Task ResolveLidAsync(string lidJid, CancellationToken ct)
+ {
+ if (!lidJid.EndsWith("@lid"))
+ return lidJid; // Not a LID, return as-is
+
+ // Check in-memory cache first
+ if (_lidToPhone.TryGetValue(lidJid, out var cached))
+ {
+ _logger.LogInformation("ResolveLidAsync: {Lid} → {Phone} (cached)", lidJid, cached);
+ return cached;
+ }
+
+ // Send usync IQ to resolve this LID
+ _logger.LogInformation("ResolveLidAsync: sending usync IQ to resolve {Lid}", lidJid);
+ var contacts = await FetchContactsViaUsyncAsync([lidJid], ct);
+
+ // Check cache again — FetchContactsViaUsyncAsync populates _lidToPhone on success
+ if (_lidToPhone.TryGetValue(lidJid, out var resolved))
+ {
+ _logger.LogInformation("ResolveLidAsync: {Lid} → {Phone} (resolved via usync)", lidJid, resolved);
+ SaveCacheToDisk();
+ return resolved;
+ }
+
+ _logger.LogWarning("ResolveLidAsync: could not resolve {Lid} (usync returned {Count} contacts, none matched)",
+ lidJid, contacts.Count);
+ return null;
+ }
+
+ ///
+ /// Returns all in-memory stored messages for a given JID.
+ /// Checks both the given JID and any known LID variant.
+ ///
+ public List GetStoredMessages(string jid)
+ {
+ var result = new List();
+ // Note: messages are stored via MessageReceived event in the service layer,
+ // not in NoiseProcessor. This returns an empty list — use WhatsAppBridgeService.GetMessagesAsync.
+ return result;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _keepAliveCts?.Cancel();
+ _keepAliveCts?.Dispose();
+ await ValueTask.CompletedTask;
+ }
+
+ // ─── Typing / presence (public API) ─────────────────────────────────────
+
+ ///
+ /// Sends a typing indicator (composing) or stops it (paused) for a specific chat.
+ ///
+ public async Task SendTypingAsync(string jid, bool isTyping, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var chatstate = new BinaryNode("chatstate",
+ new Dictionary { ["to"] = normalizedJid },
+ isTyping
+ ? new List { new BinaryNode("composing") }
+ : new List { new BinaryNode("paused") });
+ await SendNodeAsync(chatstate, ct);
+ _logger.LogInformation("Sent typing={IsTyping} to {Jid}", isTyping, normalizedJid);
+ }
+
+ ///
+ /// Updates this device's presence to available or unavailable.
+ ///
+ public async Task SendUserPresenceAsync(bool isOnline, CancellationToken ct)
+ {
+ var presence = new BinaryNode("presence", new Dictionary
+ {
+ ["type"] = isOnline ? "available" : "unavailable",
+ });
+ await SendNodeAsync(presence, ct);
+ _logger.LogInformation("Sent presence={Status}", isOnline ? "available" : "unavailable");
+ }
+
+ // ─── Message revoke ──────────────────────────────────────────────────────
+
+ ///
+ /// Sends a ProtocolMessage REVOKE to delete a sent message for everyone.
+ ///
+ public async Task RevokeMessageAsync(string jid, string messageId, bool fromMe, long timestamp, CancellationToken ct)
+ {
+ var normalizedJid = jid.Contains('@') ? jid : $"{jid.TrimStart('+')}@s.whatsapp.net";
+ var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0];
+
+ List recipientDeviceJids;
+ try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); }
+ catch { recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; }
+
+ var senderDeviceJids = new List();
+ var myJid = _auth.Me?.Id;
+ if (myJid != null)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+ try
+ {
+ var myDevices = await GetDeviceListAsync(myPhone, ct);
+ senderDeviceJids.AddRange(myDevices.Where(d => d != myJid));
+ }
+ catch { }
+ }
+
+ var revokeMsg = new Proto.WAMessage
+ {
+ ProtocolMsg = new Proto.ProtocolMessage
+ {
+ Type = Proto.ProtocolMessage.TYPE_REVOKE,
+ Key = new Proto.MessageKey
+ {
+ RemoteJid = normalizedJid,
+ FromMe = fromMe,
+ Id = messageId,
+ },
+ }
+ };
+
+ var msgBytes = revokeMsg.ToByteArrayWithRevoke();
+ var padded = PadMessage(msgBytes);
+
+ await SendEncryptedMessageAsync(normalizedJid, messageId, timestamp,
+ recipientDeviceJids, senderDeviceJids, padded, ct);
+
+ _logger.LogInformation("Sent revoke for message {MsgId} in chat {Jid}", messageId, normalizedJid);
+ }
+
+ // ─── Message forwarding ──────────────────────────────────────────────────
+
+ ///
+ /// Forwards an already-received message to another chat.
+ /// The message content is re-sent with IsForwarded=true in ContextInfo.
+ ///
+ public async Task ForwardMessageAsync(string toJid, string text, CancellationToken ct)
+ {
+ // Simple implementation: forward as extended text with forwarded context
+ var normalizedJid = toJid.Contains('@') ? toJid : $"{toJid.TrimStart('+')}@s.whatsapp.net";
+ var phoneNumber = normalizedJid.Split('@')[0].Split(':')[0];
+
+ List recipientDeviceJids;
+ try { recipientDeviceJids = await GetDeviceListAsync(phoneNumber, ct); }
+ catch { recipientDeviceJids = [$"{phoneNumber}:0@s.whatsapp.net"]; }
+
+ var senderDeviceJids = new List();
+ var myJid = _auth.Me?.Id;
+ if (myJid != null)
+ {
+ var myPhone = myJid.Split('@')[0].Split(':')[0];
+ try
+ {
+ var myDevices = await GetDeviceListAsync(myPhone, ct);
+ senderDeviceJids.AddRange(myDevices.Where(d => d != myJid));
+ }
+ catch { }
}
- else
+
+ var fwdMsg = new Proto.WAMessage
{
- // Session restore (login)
- ulong.TryParse(_auth.Me?.Id.Split('@')[0] ?? "0", out var userId);
- return new ClientPayload
+ ExtendedTextMessage = new Proto.ExtendedTextMessage
{
- Username = userId,
- Passive = true,
- Pull = true,
- ConnectType = 1,
- ConnectReason = 1,
- UserAgent = userAgent,
- WebInfo = new WebInfo { WebSubPlatform = 0 },
- }.ToByteArray();
+ Text = text,
+ ContextInfo = new Proto.ContextInfo { IsForwarded = true, ForwardingScore = 1 },
+ }
+ };
+
+ var msgBytes = fwdMsg.ToByteArray();
+ var padded = PadMessage(msgBytes);
+ var msgId = GenerateMessageId();
+
+ await SendEncryptedMessageAsync(normalizedJid, msgId,
+ DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ recipientDeviceJids, senderDeviceJids, padded, ct);
+
+ _logger.LogInformation("Forwarded message to {Jid}", normalizedJid);
+ }
+
+ // ─── Group management ────────────────────────────────────────────────────
+
+ /// Creates a new WhatsApp group and returns its JID.
+ public async Task CreateGroupAsync(string subject, IEnumerable participantJids, CancellationToken ct)
+ {
+ var participants = participantJids.Select(j =>
+ {
+ var jid = j.Contains('@') ? j : $"{j.TrimStart('+')}@s.whatsapp.net";
+ return new BinaryNode("participant", new Dictionary { ["jid"] = jid });
+ }).ToList();
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["type"] = "set",
+ ["xmlns"] = "w:g2",
+ ["to"] = "g.us",
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("create", new Dictionary { ["subject"] = subject, ["key"] = GenerateMessageId() })
+ {
+ Content = participants
+ }
+ }
+ };
+
+ try
+ {
+ var result = await SendIQAsync(iq, ct, timeoutMs: 15000);
+ var groupNode = result.FindChild("group") ?? result.FindChild("create");
+ var groupJid = groupNode?.GetAttr("jid");
+ _logger.LogInformation("Created group '{Subject}' → {Jid}", subject, groupJid ?? "(no jid in response)");
+ return groupJid;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "CreateGroupAsync failed for '{Subject}'", subject);
+ return null;
}
}
- private static string GenerateMessageId()
+ /// Leaves a WhatsApp group.
+ public async Task LeaveGroupAsync(string groupJid, CancellationToken ct)
{
- var bytes = RandomNumberGenerator.GetBytes(8);
- return BitConverter.ToString(bytes).Replace("-", "").ToUpper();
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["type"] = "set",
+ ["xmlns"] = "w:g2",
+ ["to"] = "g.us",
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("leave")
+ {
+ Content = new List
+ {
+ new BinaryNode("group", new Dictionary { ["id"] = groupJid })
+ }
+ }
+ }
+ };
+
+ try { await SendIQAsync(iq, ct, timeoutMs: 10000); }
+ catch (Exception ex) { _logger.LogWarning(ex, "LeaveGroupAsync failed for {Group}", groupJid); }
}
- public async ValueTask DisposeAsync()
+ /// Adds participants to a group. Returns per-JID result codes.
+ public async Task> AddGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct)
+ => await ModifyGroupParticipantsAsync(groupJid, jids, "add", ct);
+
+ /// Removes participants from a group. Returns per-JID result codes.
+ public async Task> RemoveGroupParticipantsAsync(string groupJid, IEnumerable jids, CancellationToken ct)
+ => await ModifyGroupParticipantsAsync(groupJid, jids, "remove", ct);
+
+ private async Task> ModifyGroupParticipantsAsync(
+ string groupJid, IEnumerable jids, string action, CancellationToken ct)
{
- // Nothing to dispose here — socket is owned by the caller
- await ValueTask.CompletedTask;
+ var participants = jids.Select(j =>
+ {
+ var jid = j.Contains('@') ? j : $"{j.TrimStart('+')}@s.whatsapp.net";
+ return new BinaryNode("participant", new Dictionary { ["jid"] = jid });
+ }).ToList();
+
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["type"] = "set",
+ ["xmlns"] = "w:g2",
+ ["to"] = groupJid,
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode(action) { Content = participants }
+ }
+ };
+
+ var results = new Dictionary();
+ try
+ {
+ var result = await SendIQAsync(iq, ct, timeoutMs: 15000);
+ // Result may have children
+ var resultNode = result.FindChild(action);
+ if (resultNode?.Content is List children)
+ {
+ foreach (var p in children)
+ {
+ var jid = p.GetAttr("jid") ?? "";
+ var err = p.GetAttr("error") ?? "200";
+ results[jid] = err;
+ }
+ }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "ModifyGroupParticipants({Action}) failed for {Group}", action, groupJid); }
+ return results;
+ }
+
+ /// Gets the group's invite link URL.
+ public async Task GetGroupInviteLinkAsync(string groupJid, CancellationToken ct)
+ {
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["type"] = "get",
+ ["xmlns"] = "w:g2",
+ ["to"] = groupJid,
+ })
+ {
+ Content = new List { new BinaryNode("invite") }
+ };
+
+ try
+ {
+ var result = await SendIQAsync(iq, ct, timeoutMs: 10000);
+ var inviteNode = result.FindChild("invite");
+ var code = inviteNode?.GetAttr("code");
+ return code != null ? $"https://chat.whatsapp.com/{code}" : null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "GetGroupInviteLinkAsync failed for {Group}", groupJid);
+ return null;
+ }
+ }
+
+ /// Updates the group subject (name).
+ public async Task UpdateGroupSubjectAsync(string groupJid, string newSubject, CancellationToken ct)
+ {
+ var iq = new BinaryNode("iq", new Dictionary
+ {
+ ["id"] = GenerateMessageId(),
+ ["type"] = "set",
+ ["xmlns"] = "w:g2",
+ ["to"] = groupJid,
+ })
+ {
+ Content = new List
+ {
+ new BinaryNode("subject") { Content = System.Text.Encoding.UTF8.GetBytes(newSubject) }
+ }
+ };
+
+ try { await SendIQAsync(iq, ct, timeoutMs: 10000); }
+ catch (Exception ex) { _logger.LogWarning(ex, "UpdateGroupSubjectAsync failed for {Group}", groupJid); }
+ }
+
+ // ─── Media download ──────────────────────────────────────────────────────
+
+ ///
+ /// Downloads and decrypts a WhatsApp media file from the CDN.
+ ///
+ /// The CDN URL from IncomingMessage.MediaUrl.
+ /// Base64-encoded media key from IncomingMessage.MediaKey.
+ /// MIME type (used to derive HKDF info string).
+ /// Decrypted plaintext bytes.
+ public static async Task DownloadMediaAsync(string mediaUrl, string mediaKeyBase64, string mimeType)
+ {
+ var mediaKey = Convert.FromBase64String(mediaKeyBase64);
+ var mediaType = mimeType.Split('/')[0] switch
+ {
+ "image" => "image",
+ "video" => "video",
+ "audio" => "audio",
+ "document" => "document",
+ _ => "image",
+ };
+
+ var encBytes = await _http.GetByteArrayAsync(mediaUrl);
+ return Crypto.MediaCrypto.Decrypt(encBytes, mediaKey, mediaType);
+ }
+
+ // ─── Delivery/read receipt tracking ─────────────────────────────────────
+
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _messageStatuses = new();
+
+ public event EventHandler<(string MessageId, Messages.MessageStatus Status)>? MessageStatusUpdated;
+
+ private async Task HandleReceiptAsync(BinaryNode node, CancellationToken ct)
+ {
+ var id = node.GetAttr("id") ?? "";
+ var from = node.GetAttr("from") ?? node.GetAttr("to") ?? "";
+ var type = node.GetAttr("type") ?? "delivery";
+ var t = long.TryParse(node.GetAttr("t"), out var rt) ? rt : 0L;
+
+ // Update delivery/read status for outgoing messages
+ var status = type switch
+ {
+ "read" => Messages.MessageStatus.Read,
+ "played" => Messages.MessageStatus.Played,
+ _ => Messages.MessageStatus.Delivered,
+ };
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ _messageStatuses[id] = status;
+ MessageStatusUpdated?.Invoke(this, (id, status));
+ _logger.LogDebug("Receipt: msg {Id} → {Status}", id, status);
+ }
+
+ // ACK the receipt
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(from))
+ {
+ try
+ {
+ using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var ack = new BinaryNode("ack", new Dictionary
+ {
+ ["id"] = id,
+ ["to"] = from,
+ ["class"] = "receipt",
+ ["t"] = t.ToString(),
+ });
+ await SendNodeAsync(ack, cts2.Token);
+ _logger.LogDebug("Sent ACK for receipt id={Id} from={From}", id, from);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to ACK receipt {Id}", id);
+ }
+ }
+
+ // If the phone tells us it couldn't decrypt one of our PDO messages, resend it
+ if (type == "retry" && _sentPdoMsgIds.Contains(id) && _lastPdoRequest.HasValue)
+ {
+ var req = _lastPdoRequest.Value;
+ _logger.LogInformation(
+ "HandleReceipt: phone can't decrypt our PDO {Id} — resending fresh PDO for chat {Chat}",
+ id, req.ChatJid);
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await RequestOnDemandHistorySyncAsync(req.ChatJid, req.Count, ct);
+ }
+ catch (Exception ex2)
+ {
+ _logger.LogWarning(ex2, "Failed to resend PDO after retry receipt");
+ }
+ }, ct);
+ }
+ else if (type == "retry")
+ {
+ // A recipient device could not decrypt one of our messages and is asking us to
+ // re-key. WhatsApp names the SPECIFIC failing device in the `participant`
+ // attribute (for multi-device / own-device fan-out); `from` is only the
+ // conversation peer. Dropping the session keyed by `from` never heals the stale
+ // own-device (or specific-device) session, so the sync copy stays undecryptable
+ // ("Waiting for this message") forever. Drop the session for the device named in
+ // `participant` (fall back to `from` for 1:1). Then the next send to that device
+ // establishes a fresh pkmsg it can read.
+ try
+ {
+ var participant = node.GetAttr("participant");
+ var failedDevice = !string.IsNullOrEmpty(participant) ? participant : from;
+
+ // Device 0 is keyed bare ("user@server", no ":0") in the send path — normalize.
+ if (failedDevice.EndsWith(":0@s.whatsapp.net", StringComparison.Ordinal))
+ failedDevice = failedDevice.Replace(":0@s.whatsapp.net", "@s.whatsapp.net");
+
+ if (!string.IsNullOrEmpty(failedDevice) && _signalStore.HasSession(failedDevice))
+ {
+ _signalStore.DeleteSession(failedDevice);
+ _logger.LogInformation("HandleReceipt: retry for {Id} — dropped stale session for failed device {Dev} (from={From}); next send re-keys.", id, failedDevice, from);
+ }
+ else
+ {
+ _logger.LogInformation("HandleReceipt: retry for {Id} failed device {Dev} (from={From}) — no stored session to drop (next send establishes fresh).", id, failedDevice, from);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "HandleReceipt: failed to drop session on retry receipt (from={From})", from);
+ }
+ }
+ }
+
+ public Messages.MessageStatus? GetMessageStatus(string messageId)
+ => _messageStatuses.TryGetValue(messageId, out var s) ? s : null;
+
+ // ─── Helper: Send encrypted message to device list ───────────────────────
+
+ ///
+ /// Shared helper used by RevokeMessageAsync and ForwardMessageAsync to encrypt
+ /// and send a pre-built padded proto blob to a list of devices.
+ ///
+ private async Task SendEncryptedMessageAsync(
+ string toJid, string msgId, long timestamp,
+ List recipientDeviceJids, List senderDeviceJids,
+ byte[] paddedProto, CancellationToken ct)
+ {
+ var allDeviceJids = new List(recipientDeviceJids);
+ allDeviceJids.AddRange(senderDeviceJids);
+
+ var needBundles = allDeviceJids.Where(d => !_signalStore.HasSession(d)).ToList();
+ if (needBundles.Count > 0)
+ {
+ try
+ {
+ var bundles = await FetchPreKeyBundlesAsync(needBundles, ct);
+ foreach (var (deviceJid, bundle) in bundles)
+ {
+ try { _signalStore.InitOutgoingSession(deviceJid, bundle, _auth); }
+ catch (Exception ex2) { _logger.LogWarning(ex2, "Failed to init session for {Jid}", deviceJid); }
+ }
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch pre-key bundles"); }
+ }
+
+ bool hasPkMsg = false;
+ var participantNodes = new List();
+ foreach (var deviceJid in recipientDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, paddedProto, ref hasPkMsg);
+ if (node != null) participantNodes.Add(node);
+ }
+
+ var senderNodes = new List();
+ foreach (var deviceJid in senderDeviceJids)
+ {
+ var node = EncryptForDevice(deviceJid, paddedProto, ref hasPkMsg);
+ if (node != null) senderNodes.Add(node);
+ }
+
+ var attrs = new Dictionary
+ {
+ ["id"] = msgId,
+ ["type"] = "text",
+ ["to"] = toJid,
+ ["t"] = timestamp.ToString(),
+ };
+
+ var msgNode = new BinaryNode("message", attrs);
+ var content = new List();
+
+ if (participantNodes.Count > 0 || senderNodes.Count > 0)
+ {
+ var pNode = new BinaryNode("participants") { Content = new List(participantNodes) };
+ content.Add(pNode);
+ if (senderNodes.Count > 0)
+ {
+ var sNode = new BinaryNode("device-sent-message") { Content = senderNodes };
+ content.Add(sNode);
+ }
+ }
+
+ msgNode.Content = content;
+ await SendNodeAsync(msgNode, ct);
+ }
+
+ // ─── History sync ────────────────────────────────────────────────────────
+
+ ///
+ /// Handles a PeerDataOperationResponseMessage (response to our ON_DEMAND history request).
+ /// Each result may contain inline compressed+encrypted history data.
+ ///
+ private Task ProcessPeerDataResponseAsync(Proto.PeerDataOperationResponseMessage response, CancellationToken ct)
+ {
+ _logger.LogInformation("PeerDataResponse: {Count} results", response.Results.Count);
+ foreach (var result in response.Results)
+ {
+ _logger.LogInformation("PeerDataResponse result: type={Type}, historyDataLen={Len}",
+ result.ResultType, result.HistoryData.Length);
+
+ if (result.ResultType != 0)
+ {
+ _logger.LogWarning("PeerDataResponse: non-OK result type {Type}", result.ResultType);
+ continue;
+ }
+
+ if (result.HistoryData.Length == 0)
+ {
+ _logger.LogInformation("PeerDataResponse: empty history data (no messages in requested range)");
+ HistorySyncCompleted?.Invoke(this, 0);
+ continue;
+ }
+
+ // History data is inline: zlib-compressed proto (same format as CDN blob after decryption)
+ // but WITHOUT the AES-CBC encryption layer — delivered inline in the response.
+ try
+ {
+ var protoBytes = ZlibInflate(result.HistoryData);
+ var sync = Proto.HistorySync.Decode(protoBytes);
+
+ _logger.LogInformation("PeerDataResponse: decoded {Convs} conversations (syncType={Type})",
+ sync.Conversations.Count, sync.SyncType);
+
+ // Reuse same emission logic
+ var myJidBase = _auth.Me?.Id?.Split(':')[0].Split('@')[0];
+ var pushNames = sync.PushNames.ToDictionary(p => p.Id, p => p.PushName);
+ var total = 0;
+ foreach (var conversation in sync.Conversations)
+ {
+ // Register every conversation so it appears in the chat list
+ _threadMetadata.TryAdd(conversation.Id, 0);
+
+ foreach (var histMsg in conversation.Messages)
+ {
+ var wmi = histMsg.Message;
+ if (wmi?.Key == null) continue;
+
+ var body = wmi.MessageBody;
+ var text = body?.EffectiveText ?? "";
+ var mediaType = body?.MediaType ?? "";
+
+ // Determine message type (text or media)
+ Messages.MessageType msgType;
+ if (!string.IsNullOrEmpty(text) && !text.StartsWith("["))
+ msgType = Messages.MessageType.Text;
+ else if (mediaType == "image") msgType = Messages.MessageType.Image;
+ else if (mediaType == "video") msgType = Messages.MessageType.Video;
+ else if (mediaType == "audio") msgType = Messages.MessageType.Audio;
+ else if (mediaType == "document") msgType = Messages.MessageType.Document;
+ else msgType = Messages.MessageType.Unknown;
+
+ // Skip messages with genuinely no content
+ if (msgType == Messages.MessageType.Unknown) continue;
+
+ // Build media URL and key for non-text messages
+ string? mediaUrl = null;
+ string? mediaKey = null;
+ string? mimeType = null;
+ string? fileName = null;
+ if (body != null && msgType != Messages.MessageType.Text)
+ {
+ var directPath = msgType == Messages.MessageType.Image ? body.ImageDirectPath :
+ msgType == Messages.MessageType.Video ? body.VideoDirectPath :
+ msgType == Messages.MessageType.Audio ? body.AudioDirectPath :
+ msgType == Messages.MessageType.Document ? body.DocumentDirectPath : "";
+ var rawKey = msgType == Messages.MessageType.Image ? body.ImageMediaKey :
+ msgType == Messages.MessageType.Video ? body.VideoMediaKey :
+ msgType == Messages.MessageType.Audio ? body.AudioMediaKey :
+ msgType == Messages.MessageType.Document ? body.DocumentMediaKey : [];
+ if (!string.IsNullOrEmpty(directPath))
+ mediaUrl = $"https://mmg.whatsapp.net{directPath}";
+ if (rawKey.Length > 0)
+ mediaKey = Convert.ToBase64String(rawKey);
+ mimeType = msgType == Messages.MessageType.Image ? "image/jpeg" :
+ msgType == Messages.MessageType.Video ? "video/mp4" :
+ msgType == Messages.MessageType.Audio ? "audio/ogg" : "application/octet-stream";
+ fileName = msgType == Messages.MessageType.Document ? body.DocumentFileName : null;
+ }
+
+ var remoteJid = wmi.Key.RemoteJid ?? conversation.Id;
+ var fromMe = wmi.Key.FromMe;
+ var senderJid = fromMe
+ ? (myJidBase != null ? $"{myJidBase}@s.whatsapp.net" : remoteJid)
+ : (wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : remoteJid);
+
+ pushNames.TryGetValue(senderJid.Split('@')[0], out var senderName);
+ var msg = new Messages.IncomingMessage
+ {
+ Id = wmi.Key.Id,
+ From = senderJid,
+ RemoteJid = remoteJid,
+ Type = msgType,
+ Text = text,
+ FromMe = fromMe,
+ Timestamp = (long)wmi.Timestamp,
+ PushName = wmi.PushName.Length > 0 ? wmi.PushName : senderName,
+ MediaUrl = mediaUrl,
+ MediaKey = mediaKey,
+ MimeType = mimeType,
+ FileName = fileName,
+ };
+ HistoryMessageReceived?.Invoke(this, msg);
+ total++;
+ }
+ }
+ _logger.LogInformation("PeerDataResponse: emitted {N} messages", total);
+ HistorySyncCompleted?.Invoke(this, total);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "PeerDataResponse: failed to decode history data ({Len} bytes)", result.HistoryData.Length);
+ }
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Downloads, decrypts, decompresses and parses a WhatsApp history sync blob,
+ /// then fires for every past message.
+ /// Called automatically when the server sends a HISTORY_SYNC_NOTIFICATION.
+ ///
+ private async Task ProcessHistorySyncAsync(Proto.HistorySyncNotification notif, CancellationToken ct)
+ {
+ try
+ {
+ var syncTypeName = notif.SyncType switch
+ {
+ Proto.HistorySyncNotification.INITIAL_BOOTSTRAP => "INITIAL_BOOTSTRAP",
+ Proto.HistorySyncNotification.RECENT => "RECENT",
+ Proto.HistorySyncNotification.FULL => "FULL",
+ Proto.HistorySyncNotification.PUSH_NAME => "PUSH_NAME",
+ Proto.HistorySyncNotification.ON_DEMAND => "ON_DEMAND",
+ _ => notif.SyncType.ToString(),
+ };
+ _logger.LogInformation("HistorySync: received {Type} notification, directPath={Path}",
+ syncTypeName, notif.DirectPath);
+
+ // Newer WA versions send InlineBlob (field 10): zlib-compressed proto, no CDN download or decryption needed.
+ // Older versions use DirectPath + MediaKey for CDN download + AES decryption.
+ byte[] protoBytes;
+
+ if (notif.InlineBlob != null && notif.InlineBlob.Length > 0)
+ {
+ _logger.LogInformation("HistorySync: InlineBlob path ({Bytes} bytes)", notif.InlineBlob.Length);
+ try
+ {
+ protoBytes = ZlibInflate(notif.InlineBlob);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: InlineBlob decompression failed");
+ return;
+ }
+ _logger.LogInformation("HistorySync: InlineBlob decompressed to {Bytes} proto bytes", protoBytes.Length);
+ }
+ else if (notif.MediaKey != null && notif.MediaKey.Length > 0 && !string.IsNullOrEmpty(notif.DirectPath))
+ {
+ // 1. Download blob from CDN
+ var downloadUrl = $"https://mmg.whatsapp.net{notif.DirectPath}";
+ _logger.LogInformation("HistorySync: downloading from {Url}", downloadUrl);
+
+ byte[] encBlob;
+ using var resp = await _http.GetAsync(downloadUrl, ct);
+ resp.EnsureSuccessStatusCode();
+ encBlob = await resp.Content.ReadAsByteArrayAsync(ct);
+ _logger.LogInformation("HistorySync: downloaded {Bytes} encrypted bytes", encBlob.Length);
+
+ // 2. Decrypt: AES-CBC with keys derived from mediaKey via HKDF "WhatsApp History Keys"
+ byte[] compressed;
+ try
+ {
+ compressed = Crypto.MediaCrypto.Decrypt(encBlob, notif.MediaKey, "md-msg-hist");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: decryption failed");
+ return;
+ }
+ _logger.LogInformation("HistorySync: decrypted to {Bytes} compressed bytes", compressed.Length);
+
+ // 3. Decompress
+ try
+ {
+ protoBytes = ZlibInflate(compressed);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: decompression failed");
+ return;
+ }
+ _logger.LogInformation("HistorySync: decompressed to {Bytes} proto bytes", protoBytes.Length);
+ }
+ else
+ {
+ _logger.LogWarning("HistorySync: no InlineBlob, no MediaKey/DirectPath — skipping");
+ return;
+ }
+
+ // 4. Proto decode
+ Proto.HistorySync sync;
+ try
+ {
+ sync = Proto.HistorySync.Decode(protoBytes);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: proto decode failed");
+ return;
+ }
+
+ // 5. Build pushname lookup
+ var pushNames = sync.PushNames.ToDictionary(p => p.Id, p => p.PushName);
+
+ // 6. Emit all messages
+ var myJidBase = _auth.Me?.Id?.Split(':')[0].Split('@')[0];
+ var totalMsgs = 0;
+ foreach (var conversation in sync.Conversations)
+ {
+ var chatJid = conversation.Id;
+ // Add conversation to thread metadata so chat appears in chat list
+ _threadMetadata.TryAdd(chatJid, 0);
+
+ foreach (var histMsg in conversation.Messages)
+ {
+ var wmi = histMsg.Message;
+ if (wmi?.Key == null) continue;
+
+ var body = wmi.MessageBody;
+ var text = body?.EffectiveText ?? "";
+ var mediaType = body?.MediaType ?? "";
+
+ // Determine message type
+ Messages.MessageType msgType;
+ if (!string.IsNullOrEmpty(text) && !text.StartsWith("["))
+ msgType = Messages.MessageType.Text;
+ else if (mediaType == "image")
+ msgType = Messages.MessageType.Image;
+ else if (mediaType == "video")
+ msgType = Messages.MessageType.Video;
+ else if (mediaType == "audio")
+ msgType = Messages.MessageType.Audio;
+ else if (mediaType == "document")
+ msgType = Messages.MessageType.Document;
+ else if (!string.IsNullOrEmpty(text)) // captions like [image], [video]
+ msgType = Messages.MessageType.Text;
+ else
+ continue; // truly unknown/empty — skip
+
+ var remoteJid = wmi.Key.RemoteJid ?? chatJid;
+ var fromMe = wmi.Key.FromMe;
+ var senderJid = fromMe
+ ? (myJidBase != null ? $"{myJidBase}@s.whatsapp.net" : remoteJid)
+ : (wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : remoteJid);
+
+ pushNames.TryGetValue(senderJid.Split('@')[0], out var senderName);
+
+ // Build media URL from directPath
+ string? mediaUrl = null;
+ string? mediaKey = null;
+ string? mimeType = null;
+ string? fileName = null;
+ if (body != null)
+ {
+ var directPath = body.ImageDirectPath.Length > 0 ? body.ImageDirectPath :
+ body.VideoDirectPath.Length > 0 ? body.VideoDirectPath :
+ body.AudioDirectPath.Length > 0 ? body.AudioDirectPath :
+ body.DocumentDirectPath.Length > 0 ? body.DocumentDirectPath : "";
+ var rawKey = body.ImageMediaKey.Length > 0 ? body.ImageMediaKey :
+ body.VideoMediaKey.Length > 0 ? body.VideoMediaKey :
+ body.AudioMediaKey.Length > 0 ? body.AudioMediaKey :
+ body.DocumentMediaKey.Length > 0 ? body.DocumentMediaKey : [];
+ if (directPath.Length > 0)
+ mediaUrl = "https://mmg.whatsapp.net" + directPath;
+ if (rawKey.Length > 0)
+ mediaKey = Convert.ToBase64String(rawKey);
+ if (body.DocumentFileName.Length > 0)
+ fileName = body.DocumentFileName;
+ mimeType = mediaType switch
+ {
+ "image" => "image/jpeg",
+ "video" => "video/mp4",
+ "audio" => "audio/ogg",
+ "document" => "application/octet-stream",
+ _ => null
+ };
+ }
+
+ var msg = new Messages.IncomingMessage
+ {
+ Id = wmi.Key.Id,
+ From = senderJid,
+ RemoteJid = remoteJid,
+ Participant = wmi.Key.Participant.Length > 0 ? wmi.Key.Participant : null,
+ Type = msgType,
+ Text = !string.IsNullOrEmpty(text) ? text : null,
+ FromMe = fromMe,
+ Timestamp = (long)wmi.Timestamp,
+ PushName = wmi.PushName.Length > 0 ? wmi.PushName : senderName,
+ MediaUrl = mediaUrl,
+ MediaKey = mediaKey,
+ MimeType = mimeType,
+ FileName = fileName,
+ };
+
+ HistoryMessageReceived?.Invoke(this, msg);
+ totalMsgs++;
+ }
+ }
+
+ _logger.LogInformation("HistorySync: emitted {Count} messages from {Convs} conversations ({Type})",
+ totalMsgs, sync.Conversations.Count, syncTypeName);
+
+ HistorySyncCompleted?.Invoke(this, totalMsgs);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HistorySync: unhandled error");
+ }
+ }
+
+ /// Decompresses zlib-deflated data (strips 2-byte zlib header + 4-byte adler32 trailer).
+ ///
+ /// Strip Signal Protocol message padding: ISO 7816-4 style padding
+ /// where the actual message is followed by 0x80 and then zero bytes.
+ /// After AES-CBC PKCS7 decryption, this layer must be removed before proto parsing.
+ ///
+ private static byte[] StripSignalPadding(byte[] data)
+ {
+ int end = data.Length - 1;
+ while (end > 0 && data[end] == 0x00) end--;
+ if (end >= 0 && data[end] == 0x80)
+ return data[..end];
+ return data; // no padding found — return as-is
+ }
+
+ private static byte[] ZlibInflate(byte[] data)
+ {
+ // zlib stream: 2-byte header + deflate data + 4-byte adler32
+ // System.IO.Compression.DeflateStream reads raw deflate (no header).
+ using var input = new System.IO.MemoryStream(data, 2, data.Length - 2);
+ using var deflate = new System.IO.Compression.DeflateStream(input, System.IO.Compression.CompressionMode.Decompress);
+ using var output = new System.IO.MemoryStream();
+ deflate.CopyTo(output);
+ return output.ToArray();
}
}
+
+public record GroupParticipant(string Jid, string LidJid, string Type);
+public record GroupMetadata(string Jid, string Subject, string Creator, long CreationTimestamp, List Participants);
+
+// PresenceInfo record — lives in Dawa.Noise namespace
+public record PresenceInfo(string Jid, string Status, DateTime LastSeen);
diff --git a/src/Dawa/Noise/NoiseState.cs b/src/Dawa/Noise/NoiseState.cs
index c6df4df..4d4b6df 100644
--- a/src/Dawa/Noise/NoiseState.cs
+++ b/src/Dawa/Noise/NoiseState.cs
@@ -42,11 +42,13 @@ public void MixHash(byte[] data)
/// Mixes input key material into the chaining key and optionally sets a new cipher key.
public void MixKey(byte[] inputKeyMaterial)
{
- // HKDF output: first 32 bytes = new encryption key (k), second 32 bytes = new chaining key (ck).
- // Matches Baileys: encKey = hashOutput.slice(0, 32), salt = hashOutput.slice(32)
- var (k, ck) = DawaHKDF.DeriveKeys(inputKeyMaterial, _ck);
- _ck = ck;
- _k = k;
+ // Noise spec + Baileys: HKDF[0:32] = new chaining key (salt for next derivation),
+ // HKDF[32:64] = new cipher key.
+ // Baileys noise.ts: const [write, read] = deriveKey(data, noiseSalt)
+ // noiseSalt = write (first 32), encKey = read (last 32)
+ var (newCk, newK) = DawaHKDF.DeriveKeys(inputKeyMaterial, _ck);
+ _ck = newCk; // HKDF[0:32] → chaining key (salt for next derivation)
+ _k = newK; // HKDF[32:64] → cipher key
_n = 0;
}
diff --git a/src/Dawa/Noise/WaVersionProvider.cs b/src/Dawa/Noise/WaVersionProvider.cs
new file mode 100644
index 0000000..ed1d480
--- /dev/null
+++ b/src/Dawa/Noise/WaVersionProvider.cs
@@ -0,0 +1,128 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Dawa.Noise;
+
+///
+/// Provides the WhatsApp Web client version announced in the ClientPayload.
+/// WhatsApp rejects fresh device registrations from outdated versions with
+/// "Authentication failure: 405" (already-paired sessions keep working), so the
+/// version must track WhatsApp's minimum. Baileys maintains the current value in
+/// baileys-version.json; we fetch it before each connect (throttled) and fall back
+/// to the compiled constant when offline. The active version is mirrored to
+/// wa-version-active.json next to the app so external watchdogs can compare it
+/// against the live upstream value.
+///
+public static class WaVersionProvider
+{
+ public const uint Primary = 2;
+ public const uint Secondary = 3000;
+
+ // Last version verified working, 2026-07-14. Used when the fetch fails and no disk cache exists.
+ private const uint FallbackTertiary = 1035194821;
+
+ private const string SourceUrl =
+ "https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/baileys-version.json";
+
+ private static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(12);
+ private static readonly TimeSpan RetryInterval = TimeSpan.FromHours(1);
+ private static readonly SemaphoreSlim Gate = new(1, 1);
+
+ private static uint _tertiary = FallbackTertiary;
+ private static DateTime _nextFetchUtc = DateTime.MinValue;
+
+ static WaVersionProvider() => TryReadCache();
+
+ public static uint Tertiary => _tertiary;
+ public static string VersionString => $"{Primary}.{Secondary}.{_tertiary}";
+
+ ///
+ /// Refreshes the version from upstream if the throttle window has passed.
+ /// Never throws — on any failure the current (cached or fallback) value stays active.
+ ///
+ public static async Task RefreshAsync(ILogger? logger = null, CancellationToken ct = default)
+ {
+ if (DateTime.UtcNow < _nextFetchUtc) return;
+
+ await Gate.WaitAsync(ct);
+ try
+ {
+ if (DateTime.UtcNow < _nextFetchUtc) return;
+
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
+ var json = await http.GetStringAsync(SourceUrl, ct);
+ using var doc = JsonDocument.Parse(json);
+ var arr = doc.RootElement.GetProperty("version");
+
+ if (arr.GetArrayLength() == 3
+ && arr[0].GetUInt32() == Primary
+ && arr[1].GetUInt32() == Secondary)
+ {
+ var tertiary = arr[2].GetUInt32();
+ if (tertiary != _tertiary)
+ {
+ logger?.LogInformation("WA web version updated: {Old} -> {New}", _tertiary, tertiary);
+ _tertiary = tertiary;
+ }
+ _nextFetchUtc = DateTime.UtcNow + RefreshInterval;
+ TryWriteCache();
+ }
+ else
+ {
+ // Major/minor changed upstream — a protocol shift beyond a tertiary bump.
+ // Keep the known-good value; a human (or watchdog alert) must look at this.
+ logger?.LogWarning("WA version upstream has unexpected shape: {Json} — keeping {Current}",
+ json.Trim(), VersionString);
+ _nextFetchUtc = DateTime.UtcNow + RefreshInterval;
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogWarning("WA version fetch failed ({Msg}) — using {Current}", ex.Message, VersionString);
+ _nextFetchUtc = DateTime.UtcNow + RetryInterval;
+ }
+ finally
+ {
+ Gate.Release();
+ }
+ }
+
+ private static string CachePath => Path.Combine(AppContext.BaseDirectory, "wa-version-active.json");
+
+ private static void TryWriteCache()
+ {
+ try
+ {
+ File.WriteAllText(CachePath, JsonSerializer.Serialize(new
+ {
+ version = new[] { Primary, Secondary, _tertiary },
+ fetchedUtc = DateTime.UtcNow,
+ }));
+ }
+ catch
+ {
+ // Cache is best-effort; the in-memory value is what matters.
+ }
+ }
+
+ private static void TryReadCache()
+ {
+ try
+ {
+ if (!File.Exists(CachePath)) return;
+ using var doc = JsonDocument.Parse(File.ReadAllText(CachePath));
+ var arr = doc.RootElement.GetProperty("version");
+ if (arr.GetArrayLength() == 3
+ && arr[0].GetUInt32() == Primary
+ && arr[1].GetUInt32() == Secondary
+ && arr[2].GetUInt32() > FallbackTertiary)
+ {
+ _tertiary = arr[2].GetUInt32();
+ }
+ }
+ catch
+ {
+ // Corrupt cache — fall back to the compiled constant.
+ }
+ }
+}
diff --git a/src/Dawa/Proto/HistorySyncProto.cs b/src/Dawa/Proto/HistorySyncProto.cs
new file mode 100644
index 0000000..5f3f075
--- /dev/null
+++ b/src/Dawa/Proto/HistorySyncProto.cs
@@ -0,0 +1,337 @@
+namespace Dawa.Proto;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// History-sync proto decoders.
+// Field numbers verified against Baileys WAProto/index.js encode methods.
+// Uses the existing ProtoReader from ProtoEncoder.cs (byte[] based).
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// Embedded in ProtocolMessage field 6 (HISTORY_SYNC_NOTIFICATION).
+/// Points to the encrypted, compressed HistorySync blob on the CDN.
+///
+public sealed class HistorySyncNotification
+{
+ public byte[] FileSha256 { get; set; } = []; // field 1
+ public ulong FileLength { get; set; } // field 2
+ public byte[] MediaKey { get; set; } = []; // field 3
+ public byte[] FileEncSha256 { get; set; } = []; // field 4
+ public string DirectPath { get; set; } = ""; // field 5
+ public int SyncType { get; set; } // field 6
+ public int Progress { get; set; } // field 9
+ public byte[] InlineBlob { get; set; } = []; // field 10 (newer WA: zlib blob sent inline)
+
+ public const int INITIAL_BOOTSTRAP = 0;
+ public const int RECENT = 2;
+ public const int FULL = 3;
+ public const int PUSH_NAME = 4;
+ public const int ON_DEMAND = 5;
+ public const int NON_BLOCKING_DATA = 6;
+
+ public static HistorySyncNotification Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistorySyncNotification();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.FileSha256 = r.ReadBytes(); break;
+ case 2: obj.FileLength = r.ReadVarint(); break;
+ case 3: obj.MediaKey = r.ReadBytes(); break;
+ case 4: obj.FileEncSha256 = r.ReadBytes(); break;
+ case 5: obj.DirectPath = r.ReadString(); break;
+ case 6: obj.SyncType = r.ReadInt32(); break;
+ case 9: obj.Progress = r.ReadInt32(); break;
+ case 10: obj.InlineBlob = r.ReadBytes(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+/// Top-level proto decoded from the decrypted+decompressed history blob.
+public sealed class HistorySync
+{
+ public int SyncType { get; set; }
+ public List Conversations { get; set; } = [];
+ public List PushNames { get; set; } = [];
+
+ public static HistorySync Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistorySync();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.SyncType = r.ReadInt32(); break;
+ case 2: obj.Conversations.Add(Conversation.Decode(r.ReadBytes())); break;
+ case 7: obj.PushNames.Add(Pushname.Decode(r.ReadBytes())); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+public sealed class Conversation
+{
+ public string Id { get; set; } = "";
+ public List Messages { get; set; } = [];
+ public string Name { get; set; } = "";
+ public bool Archived { get; set; }
+
+ public static Conversation Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new Conversation();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.Id = r.ReadString(); break;
+ case 2: obj.Messages.Add(HistorySyncMsg.Decode(r.ReadBytes())); break;
+ case 13: obj.Name = r.ReadString(); break;
+ case 16: obj.Archived = r.ReadBool(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+public sealed class HistorySyncMsg
+{
+ public HistoryWebMessageInfo? Message { get; set; }
+
+ public static HistorySyncMsg Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistorySyncMsg();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.Message = HistoryWebMessageInfo.Decode(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+/// Minimal decode of WebMessageInfo — only fields needed for history display.
+public sealed class HistoryWebMessageInfo
+{
+ public HistoryMessageKey? Key { get; set; } // field 1
+ public HistoryWaMessage? MessageBody { get; set; } // field 2
+ public ulong Timestamp { get; set; } // field 3
+ public string Participant { get; set; } = ""; // field 5 (group sender)
+ public string PushName { get; set; } = ""; // field 19
+
+ public static HistoryWebMessageInfo Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistoryWebMessageInfo();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.Key = HistoryMessageKey.Decode(r.ReadBytes()); break;
+ case 2: obj.MessageBody = HistoryWaMessage.Decode(r.ReadBytes()); break;
+ case 3: obj.Timestamp = r.ReadVarint(); break;
+ case 5: obj.Participant = r.ReadString(); break;
+ case 19: obj.PushName = r.ReadString(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+public sealed class HistoryMessageKey
+{
+ public string RemoteJid { get; set; } = "";
+ public bool FromMe { get; set; }
+ public string Id { get; set; } = "";
+ public string Participant { get; set; } = ""; // field 4 (group sender)
+
+ public static HistoryMessageKey Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistoryMessageKey();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.RemoteJid = r.ReadString(); break;
+ case 2: obj.FromMe = r.ReadBool(); break;
+ case 3: obj.Id = r.ReadString(); break;
+ case 4: obj.Participant = r.ReadString(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
+
+/// Minimal Message decode — plain text, captions, and media metadata.
+public sealed class HistoryWaMessage
+{
+ public string Conversation { get; set; } = ""; // field 1 — plain text
+ public string ExtendedText { get; set; } = ""; // field 6 → ExtendedTextMessage.text (field 1)
+ public string ImageCaption { get; set; } = ""; // field 4 → ImageMessage.caption (field 7)
+ public string ImageDirectPath { get; set; } = ""; // field 4 → ImageMessage.directPath (field 10)
+ public byte[] ImageMediaKey { get; set; } = []; // field 4 → ImageMessage.mediaKey (field 19)
+ public string VideoCaption { get; set; } = ""; // field 13→ VideoMessage.caption (field 7)
+ public string VideoDirectPath { get; set; } = ""; // field 13→ VideoMessage.directPath (field 10)
+ public byte[] VideoMediaKey { get; set; } = []; // field 13→ VideoMessage.mediaKey (field 19)
+ public string AudioDirectPath { get; set; } = ""; // field 26→ AudioMessage.directPath (field 10)
+ public byte[] AudioMediaKey { get; set; } = []; // field 26→ AudioMessage.mediaKey (field 19)
+ public string DocumentFileName { get; set; } = ""; // field 8 → DocumentMessage.fileName (field 4)
+ public string DocumentDirectPath { get; set; } = ""; // field 8 → DocumentMessage.directPath (field 10)
+ public byte[] DocumentMediaKey { get; set; } = []; // field 8 → DocumentMessage.mediaKey (field 19)
+
+ // Media type detected
+ public string MediaType =>
+ ImageDirectPath.Length > 0 || ImageMediaKey.Length > 0 ? "image" :
+ VideoDirectPath.Length > 0 || VideoMediaKey.Length > 0 ? "video" :
+ AudioDirectPath.Length > 0 || AudioMediaKey.Length > 0 ? "audio" :
+ DocumentDirectPath.Length > 0 ? "document" :
+ "";
+
+ public string EffectiveText =>
+ Conversation.Length > 0 ? Conversation :
+ ExtendedText.Length > 0 ? ExtendedText :
+ ImageCaption.Length > 0 ? $"[image] {ImageCaption}".Trim() :
+ VideoCaption.Length > 0 ? $"[video] {VideoCaption}".Trim() :
+ MediaType.Length > 0 ? $"[{MediaType}]" :
+ "";
+
+ public static HistoryWaMessage Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new HistoryWaMessage();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.Conversation = r.ReadString(); break;
+ case 4: ReadImageMessage(r.ReadBytes(), obj); break; // ImageMessage
+ case 6: obj.ExtendedText = ReadTextField1(r.ReadBytes()); break; // ExtendedTextMessage
+ case 8: ReadDocumentMessage(r.ReadBytes(), obj); break; // DocumentMessage
+ case 13: ReadVideoMessage(r.ReadBytes(), obj); break; // VideoMessage
+ case 26: ReadAudioMessage(r.ReadBytes(), obj); break; // AudioMessage (field 26)
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+
+ private static string ReadTextField1(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ if (f == 1) return r.ReadString();
+ r.Skip(wt);
+ }
+ return "";
+ }
+
+ private static void ReadImageMessage(byte[] data, HistoryWaMessage obj)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ switch (f)
+ {
+ case 7: obj.ImageCaption = r.ReadString(); break; // caption
+ case 10: obj.ImageDirectPath = r.ReadString(); break; // directPath
+ case 19: obj.ImageMediaKey = r.ReadBytes(); break; // mediaKey
+ default: r.Skip(wt); break;
+ }
+ }
+ }
+
+ private static void ReadVideoMessage(byte[] data, HistoryWaMessage obj)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ switch (f)
+ {
+ case 7: obj.VideoCaption = r.ReadString(); break; // caption
+ case 10: obj.VideoDirectPath = r.ReadString(); break; // directPath
+ case 19: obj.VideoMediaKey = r.ReadBytes(); break; // mediaKey
+ default: r.Skip(wt); break;
+ }
+ }
+ }
+
+ private static void ReadAudioMessage(byte[] data, HistoryWaMessage obj)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ switch (f)
+ {
+ case 10: obj.AudioDirectPath = r.ReadString(); break; // directPath
+ case 19: obj.AudioMediaKey = r.ReadBytes(); break; // mediaKey
+ default: r.Skip(wt); break;
+ }
+ }
+ }
+
+ private static void ReadDocumentMessage(byte[] data, HistoryWaMessage obj)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ switch (f)
+ {
+ case 4: obj.DocumentFileName = r.ReadString(); break; // fileName
+ case 10: obj.DocumentDirectPath = r.ReadString(); break; // directPath
+ case 19: obj.DocumentMediaKey = r.ReadBytes(); break; // mediaKey
+ default: r.Skip(wt); break;
+ }
+ }
+ }
+}
+
+public sealed class Pushname
+{
+ public string Id { get; set; } = "";
+ public string PushName { get; set; } = "";
+
+ public static Pushname Decode(byte[] data)
+ {
+ var r = ProtoEncoder.CreateReader(data);
+ var obj = new Pushname();
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.Id = r.ReadString(); break;
+ case 2: obj.PushName = r.ReadString(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+}
diff --git a/src/Dawa/Proto/ProtoEncoder.cs b/src/Dawa/Proto/ProtoEncoder.cs
index f8ea5ef..07efc2e 100644
--- a/src/Dawa/Proto/ProtoEncoder.cs
+++ b/src/Dawa/Proto/ProtoEncoder.cs
@@ -122,6 +122,8 @@ public ulong ReadVarint()
int shift = 0;
while (true)
{
+ if (_pos >= _data.Length)
+ throw new InvalidDataException($"Protobuf varint read past end of buffer at pos={_pos}, len={_data.Length}");
var b = _data[_pos++];
result |= ((ulong)(b & 0x7F)) << shift;
if ((b & 0x80) == 0) break;
@@ -133,6 +135,11 @@ public ulong ReadVarint()
public byte[] ReadBytes()
{
var len = (int)ReadVarint();
+ if (len < 0 || _pos + len > _data.Length)
+ {
+ _pos = _data.Length; // stop parsing gracefully
+ return [];
+ }
var data = _data[_pos..(_pos + len)];
_pos += len;
return data;
@@ -150,9 +157,26 @@ public void Skip(int wireType)
switch (wireType)
{
case 0: ReadVarint(); break;
- case 1: _pos += 8; break;
- case 2: _pos += (int)ReadVarint(); break;
- case 5: _pos += 4; break;
+ case 1:
+ if (_pos + 8 > _data.Length) { _pos = _data.Length; break; }
+ _pos += 8; break;
+ case 2:
+ var len2 = (int)ReadVarint();
+ if (len2 < 0 || _pos + len2 > _data.Length) { _pos = _data.Length; break; }
+ _pos += len2; break;
+ case 5:
+ if (_pos + 4 > _data.Length) { _pos = _data.Length; break; }
+ _pos += 4; break;
+ case 3: // SGROUP: skip until matching EGROUP (wire type 4)
+ while (HasMore)
+ {
+ var (_, wt) = ReadTag();
+ if (wt == 4) break; // EGROUP
+ Skip(wt);
+ }
+ break;
+ case 4: break; // EGROUP — consumed by parent SGROUP skip
+ default: _pos = _data.Length; break; // unknown wire type — stop parsing this message
}
}
}
diff --git a/src/Dawa/Proto/SyncProto.cs b/src/Dawa/Proto/SyncProto.cs
new file mode 100644
index 0000000..e3da999
--- /dev/null
+++ b/src/Dawa/Proto/SyncProto.cs
@@ -0,0 +1,195 @@
+namespace Dawa.Proto;
+
+// SyncdSnapshot (returned in node of app state sync IQ result)
+// field 1 = SyncdVersion (message, skip)
+// field 2 = SyncdRecord[] (repeated message)
+// field 3 = mac (bytes, skip)
+// field 4 = keyId (SyncdIndex message → field 1 = blob bytes)
+public sealed class SyncdSnapshot
+{
+ public List Records { get; } = [];
+ public byte[] KeyId { get; set; } = [];
+
+ public static SyncdSnapshot ParseFrom(byte[] data)
+ {
+ var msg = new SyncdSnapshot();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 2: msg.Records.Add(SyncdRecord.ParseFrom(r.ReadBytes())); break;
+ case 4: msg.KeyId = ParseIndex(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+
+ private static byte[] ParseIndex(byte[] data)
+ {
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ if (field == 1) return r.ReadBytes();
+ r.Skip(wt);
+ }
+ return [];
+ }
+}
+
+// SyncdRecord
+// field 1 = index (SyncdIndex → blob)
+// field 2 = value (SyncdValue → blob) -- this is the encrypted payload
+// field 3 = keyId (SyncdIndex → blob)
+public sealed class SyncdRecord
+{
+ public byte[] IndexBlob { get; set; } = [];
+ public byte[] ValueBlob { get; set; } = []; // IV(16) + ciphertext
+ public byte[] KeyId { get; set; } = [];
+
+ public static SyncdRecord ParseFrom(byte[] data)
+ {
+ var msg = new SyncdRecord();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.IndexBlob = ParseBlob(r.ReadBytes()); break;
+ case 2: msg.ValueBlob = ParseBlob(r.ReadBytes()); break;
+ case 3: msg.KeyId = ParseBlob(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+
+ private static byte[] ParseBlob(byte[] data)
+ {
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ if (field == 1) return r.ReadBytes();
+ r.Skip(wt);
+ }
+ return [];
+ }
+}
+
+// SyncActionData (decrypted payload of each SyncdRecord.ValueBlob)
+// field 1 = index (bytes → UTF-8 JSON, e.g. ["contact","31612345678@s.whatsapp.net"])
+// field 2 = value (SyncActionValue)
+public sealed class SyncActionData
+{
+ public string[] Index { get; set; } = []; // parsed from JSON bytes
+ public SyncActionValue? Value { get; set; }
+
+ public static SyncActionData ParseFrom(byte[] data)
+ {
+ var msg = new SyncActionData();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1:
+ var indexJson = System.Text.Encoding.UTF8.GetString(r.ReadBytes());
+ // JSON array like ["contact","31612345678@s.whatsapp.net"]
+ try { msg.Index = System.Text.Json.JsonSerializer.Deserialize(indexJson) ?? []; }
+ catch { msg.Index = []; }
+ break;
+ case 2: msg.Value = SyncActionValue.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+}
+
+// SyncActionValue
+// field 1 = timestamp (int64, skip)
+// field 3 = contactAction (SyncActionContactAction)
+// field 5 = archiveAction (SyncActionChatAction — field 1 = archived bool)
+// field 6 = pinAction (inner message — field 1 = pinned bool)
+// (many other field types we don't need — skip all)
+public sealed class SyncActionValue
+{
+ public SyncActionContactAction? ContactAction { get; set; }
+ public SyncActionChatAction? ChatAction { get; set; }
+
+ public static SyncActionValue ParseFrom(byte[] data)
+ {
+ var msg = new SyncActionValue();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 3: msg.ContactAction = SyncActionContactAction.ParseFrom(r.ReadBytes()); break;
+ case 5: msg.ChatAction = SyncActionChatAction.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+}
+
+// SyncActionContactAction
+// field 1 = fullName (string)
+// field 2 = firstName (string)
+public sealed class SyncActionContactAction
+{
+ public string FullName { get; set; } = "";
+ public string FirstName { get; set; } = "";
+
+ public static SyncActionContactAction ParseFrom(byte[] data)
+ {
+ var msg = new SyncActionContactAction();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.FullName = r.ReadString(); break;
+ case 2: msg.FirstName = r.ReadString(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+}
+
+// SyncActionChatAction — parsed from chat entries in regular_low / regular collections
+// field 1 = archived (bool)
+// field 5 = pinAction (inner message, field 1 = bool)
+// field 6 = muteAction (inner message, field 1 = epoch ms)
+public sealed class SyncActionChatAction
+{
+ public bool Archived { get; set; }
+ public bool Pinned { get; set; }
+ public bool Muted { get; set; }
+
+ public static SyncActionChatAction ParseFrom(byte[] data)
+ {
+ var msg = new SyncActionChatAction();
+ var r = new ProtoReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Archived = r.ReadBool(); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return msg;
+ }
+}
diff --git a/src/Dawa/Proto/WAMessage.cs b/src/Dawa/Proto/WAMessage.cs
index 1970741..2af29cc 100644
--- a/src/Dawa/Proto/WAMessage.cs
+++ b/src/Dawa/Proto/WAMessage.cs
@@ -64,18 +64,46 @@ public byte[] ToByteArray()
/// DeviceProps (companion registration info), field 8 of DevicePairingRegistrationData.
public sealed class DevicePropsMessage
{
- public string Os { get; set; } = "Windows";
+ public string Os { get; set; } = "Ubuntu";
+ // Version = WA app version (Baileys sends [2, 3000, tertiary]), NOT the OS/browser version.
+ public AppVersion? Version { get; set; } = new AppVersion { Primary = 2, Secondary = 3000, Tertiary = 1035194821 };
// PlatformType: CHROME=1
public int PlatformType { get; set; } = 1;
- public bool RequireFullSync { get; set; } = false;
+ // requireFullSync: true triggers the server to include history in initial sync (Baileys default: true)
+ public bool RequireFullSync { get; set; } = true;
+ public HistorySyncConfigMessage? HistorySyncConfig { get; set; } = new();
public byte[] ToByteArray()
{
- // Match Baileys exactly: only os (field 1) + platformType (field 3).
- // requireFullSync=false is proto3 default — omit. No version, no HistorySyncConfig.
var buf = new List();
ProtoEncoder.WriteString(buf, 1, Os);
+ if (Version != null) ProtoEncoder.WriteMessage(buf, 2, Version.ToByteArray());
ProtoEncoder.WriteInt32(buf, 3, PlatformType);
+ ProtoEncoder.WriteBool(buf, 4, RequireFullSync); // always emit — server expects it
+ if (HistorySyncConfig != null) ProtoEncoder.WriteMessage(buf, 5, HistorySyncConfig.ToByteArray());
+ return [.. buf];
+ }
+}
+
+/// HistorySyncConfig nested in DeviceProps (field 5).
+public sealed class HistorySyncConfigMessage
+{
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteUInt32(buf, 1, 3); // fullSyncDaysLimit = 3 (Baileys: fullCount)
+ ProtoEncoder.WriteUInt32(buf, 3, 2048); // storageQuotaMb = 2048 (Baileys default)
+ ProtoEncoder.WriteBool(buf, 4, true); // inlineInitialPayloadInE2EeMsg
+ ProtoEncoder.WriteUInt32(buf, 5, 25); // recentSyncChunkSize = 25 (Baileys: count)
+ // field 6: supportCallLogHistory = false — omit
+ ProtoEncoder.WriteBool(buf, 7, true); // supportBotUserAgentChatHistory
+ ProtoEncoder.WriteBool(buf, 8, true); // supportCagReactionsAndPolls
+ ProtoEncoder.WriteBool(buf, 9, true); // supportBizHostedMsg
+ ProtoEncoder.WriteBool(buf, 10, true); // supportRecentSyncChunkMessageCountTuning
+ ProtoEncoder.WriteBool(buf, 11, true); // supportHostedGroupMsg
+ ProtoEncoder.WriteBool(buf, 12, true); // supportFbidBotChatHistory
+ ProtoEncoder.WriteBool(buf, 14, true); // supportMessageAssociation
+ // field 15: supportGroupHistory = false — omit
return [.. buf];
}
}
@@ -144,18 +172,268 @@ public byte[] ToByteArray()
}
}
-/// WhatsApp message content proto.
+// ─────────────────────────────────────────────────────────────
+// ADV (Advanced Device Verification) protos used in QR pairing.
+// Field numbers verified against WAProto.proto from Baileys.
+// ─────────────────────────────────────────────────────────────
+
+/// Server sends this in device-identity during pair-success. Contains HMAC-protected device identity.
+public sealed class ADVSignedDeviceIdentityHMAC
+{
+ public byte[] Details { get; set; } = []; // field 1: encoded ADVSignedDeviceIdentity
+ public byte[] Hmac { get; set; } = []; // field 2: HMAC-SHA256 over details
+ public int AccountType { get; set; } = 0; // field 3: 0=E2EE, 1=HOSTED
+
+ public static ADVSignedDeviceIdentityHMAC ParseFrom(byte[] data)
+ {
+ var msg = new ADVSignedDeviceIdentityHMAC();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Details = r.ReadBytes(); break;
+ case 2: msg.Hmac = r.ReadBytes(); break;
+ case 3: msg.AccountType = r.ReadInt32(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Decoded from ADVSignedDeviceIdentityHMAC.Details. Client adds deviceSignature and re-encodes.
+public sealed class ADVSignedDeviceIdentity
+{
+ public byte[] Details { get; set; } = []; // field 1: encoded ADVDeviceIdentity
+ public byte[] AccountSignatureKey { get; set; } = []; // field 2: phone's Curve25519 public key
+ public byte[] AccountSignature { get; set; } = []; // field 3: phone's XEdDSA signature
+ public byte[] DeviceSignature { get; set; } = []; // field 4: client fills this in
+
+ public static ADVSignedDeviceIdentity ParseFrom(byte[] data)
+ {
+ var msg = new ADVSignedDeviceIdentity();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Details = r.ReadBytes(); break;
+ case 2: msg.AccountSignatureKey = r.ReadBytes(); break;
+ case 3: msg.AccountSignature = r.ReadBytes(); break;
+ case 4: msg.DeviceSignature = r.ReadBytes(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+
+ /// Encode all fields including accountSignatureKey (for device-identity in outgoing pkmsg).
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteBytes(buf, 1, Details);
+ if (AccountSignatureKey.Length > 0) ProtoEncoder.WriteBytes(buf, 2, AccountSignatureKey);
+ ProtoEncoder.WriteBytes(buf, 3, AccountSignature);
+ ProtoEncoder.WriteBytes(buf, 4, DeviceSignature);
+ return [.. buf];
+ }
+
+ /// Encode WITHOUT accountSignatureKey (field 2 omitted per Baileys protocol).
+ public byte[] ToByteArrayForReply()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteBytes(buf, 1, Details);
+ // field 2 (accountSignatureKey) intentionally omitted in reply
+ ProtoEncoder.WriteBytes(buf, 3, AccountSignature);
+ ProtoEncoder.WriteBytes(buf, 4, DeviceSignature);
+ return [.. buf];
+ }
+}
+
+/// Decoded from ADVSignedDeviceIdentity.Details. Used to get keyIndex for the reply.
+public sealed class ADVDeviceIdentity
+{
+ public uint RawId { get; set; } // field 1
+ public ulong Timestamp { get; set; } // field 2
+ public uint KeyIndex { get; set; } // field 3
+ public int AccountType { get; set; } // field 4
+ public int DeviceType { get; set; } // field 5
+
+ public static ADVDeviceIdentity ParseFrom(byte[] data)
+ {
+ var msg = new ADVDeviceIdentity();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.RawId = r.ReadUInt32(); break;
+ case 2: msg.Timestamp = r.ReadUInt64(); break;
+ case 3: msg.KeyIndex = r.ReadUInt32(); break;
+ case 4: msg.AccountType = r.ReadInt32(); break;
+ case 5: msg.DeviceType = r.ReadInt32(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+///
+/// WhatsApp Message proto (field numbers from Baileys WAProto.proto).
+/// Only the fields we need for text message extraction are implemented;
+/// all others are safely skipped.
+///
public sealed class WAMessage
{
- public string? Conversation { get; set; }
- public ExtendedTextMessage? ExtendedTextMessage { get; set; }
+ public string? Conversation { get; set; } // field 1
+ public SenderKeyDistributionMessage? SenderKeyDist { get; set; } // field 2
+ public ImageMessage? ImageMessage { get; set; } // field 3
+ public AudioMessage? AudioMessage { get; set; } // field 4
+ public ExtendedTextMessage? ExtendedTextMessage { get; set; } // field 6
+ public VideoMessage? VideoMessage { get; set; } // field 5
+ public StickerMessage? StickerMessage { get; set; } // field 50
+ public ProtocolMessage? ProtocolMsg { get; set; } // field 12
+ public DocumentMessage? DocumentMessage { get; set; } // field 15
+ public DeviceSentMessage? DeviceSentMessage { get; set; } // field 31
+ public MessageContextInfo? MessageContextInfo { get; set; } // field 35
+ public HistorySyncNotification? HistorySyncNotification { get; set; } // field 46
+ public ReactionMessage? ReactionMessage { get; set; } // field 85
+ public PeerDataOperationRequestMessage? PeerDataOperation { get; set; } // field 145
+ public PeerDataOperationResponseMessage? PeerDataResponse { get; set; } // field 146
+
+ /// Extracts the text from whatever message type this is.
+ public string? GetText()
+ {
+ if (!string.IsNullOrEmpty(Conversation))
+ return Conversation;
+ if (ExtendedTextMessage != null && !string.IsNullOrEmpty(ExtendedTextMessage.Text))
+ return ExtendedTextMessage.Text;
+ if (DeviceSentMessage?.Message != null)
+ return DeviceSentMessage.Message.GetText();
+ // Caption from media messages
+ if (ImageMessage != null && !string.IsNullOrEmpty(ImageMessage.Caption)) return ImageMessage.Caption;
+ if (VideoMessage != null && !string.IsNullOrEmpty(VideoMessage.Caption)) return VideoMessage.Caption;
+ if (DocumentMessage != null && !string.IsNullOrEmpty(DocumentMessage.Title)) return DocumentMessage.Title;
+ return null;
+ }
+
+ /// Returns the MessageType enum value for this message.
+ public Messages.MessageType GetMessageType()
+ {
+ if (DeviceSentMessage?.Message != null) return DeviceSentMessage.Message.GetMessageType();
+ if (!string.IsNullOrEmpty(Conversation)) return Messages.MessageType.Text;
+ if (ExtendedTextMessage != null) return Messages.MessageType.Text;
+ if (ImageMessage != null) return Messages.MessageType.Image;
+ if (AudioMessage != null) return Messages.MessageType.Audio;
+ if (VideoMessage != null) return Messages.MessageType.Video;
+ if (DocumentMessage != null) return Messages.MessageType.Document;
+ if (StickerMessage != null) return Messages.MessageType.Sticker;
+ if (ReactionMessage != null) return Messages.MessageType.Reaction;
+ if (ProtocolMsg != null) return Messages.MessageType.Protocol;
+ return Messages.MessageType.Unknown;
+ }
+
+ /// Extracts all fields needed to populate an IncomingMessage from this WAMessage.
+ public (Messages.MessageType type, string? text, string? mediaUrl, string? mimeType,
+ string? fileName, long? fileSize, uint? duration, uint? width, uint? height,
+ string? mediaKey, string? mediaSha256Enc, string? reactionEmoji, string? reactionTargetId)
+ GetAllFields()
+ {
+ var inner = DeviceSentMessage?.Message;
+ if (inner != null) return inner.GetAllFields();
+
+ var type = GetMessageType();
+ var text = GetText();
+
+ if (ImageMessage != null)
+ return (type, text ?? ImageMessage.Caption,
+ mediaUrl: ImageMessage.Url,
+ mimeType: ImageMessage.MimeType,
+ fileName: null,
+ fileSize: (long?)ImageMessage.FileLength,
+ duration: null,
+ width: ImageMessage.Width,
+ height: ImageMessage.Height,
+ mediaKey: Convert.ToBase64String(ImageMessage.MediaKey),
+ mediaSha256Enc:Convert.ToBase64String(ImageMessage.FileEncSha256),
+ null, null);
+
+ if (AudioMessage != null)
+ return (type, text,
+ mediaUrl: AudioMessage.Url,
+ mimeType: AudioMessage.MimeType,
+ fileName: null,
+ fileSize: (long?)AudioMessage.FileLength,
+ duration: AudioMessage.Seconds,
+ width: null,
+ height: null,
+ mediaKey: Convert.ToBase64String(AudioMessage.MediaKey),
+ mediaSha256Enc:Convert.ToBase64String(AudioMessage.FileEncSha256),
+ null, null);
+
+ if (VideoMessage != null)
+ return (type, text,
+ mediaUrl: VideoMessage.Url,
+ mimeType: VideoMessage.MimeType,
+ fileName: null,
+ fileSize: (long?)VideoMessage.FileLength,
+ duration: VideoMessage.Seconds,
+ width: VideoMessage.Width,
+ height: VideoMessage.Height,
+ mediaKey: Convert.ToBase64String(VideoMessage.MediaKey),
+ mediaSha256Enc:Convert.ToBase64String(VideoMessage.FileEncSha256),
+ null, null);
+
+ if (DocumentMessage != null)
+ return (type, text,
+ mediaUrl: DocumentMessage.Url,
+ mimeType: DocumentMessage.MimeType,
+ fileName: DocumentMessage.FileName,
+ fileSize: (long?)DocumentMessage.FileLength,
+ duration: null,
+ width: null,
+ height: null,
+ mediaKey: Convert.ToBase64String(DocumentMessage.MediaKey),
+ mediaSha256Enc:Convert.ToBase64String(DocumentMessage.FileEncSha256),
+ null, null);
+
+ if (StickerMessage != null)
+ return (type, text,
+ mediaUrl: StickerMessage.Url,
+ mimeType: StickerMessage.MimeType,
+ fileName: null,
+ fileSize: (long?)StickerMessage.FileLength,
+ duration: null,
+ width: StickerMessage.Width,
+ height: StickerMessage.Height,
+ mediaKey: Convert.ToBase64String(StickerMessage.MediaKey),
+ mediaSha256Enc:Convert.ToBase64String(StickerMessage.FileEncSha256),
+ null, null);
+
+ if (ReactionMessage != null)
+ return (type, null, null, null, null, null, null, null, null, null, null,
+ reactionEmoji: ReactionMessage.Text,
+ reactionTargetId: ReactionMessage.Key?.Id);
+
+ return (type, text, null, null, null, null, null, null, null, null, null, null, null);
+ }
public byte[] ToByteArray()
{
var buf = new List();
ProtoEncoder.WriteString(buf, 1, Conversation);
+ if (ImageMessage != null) ProtoEncoder.WriteMessage(buf, 3, ImageMessage.ToByteArray());
+ if (AudioMessage != null) ProtoEncoder.WriteMessage(buf, 4, AudioMessage.ToByteArray());
if (ExtendedTextMessage != null)
- ProtoEncoder.WriteMessage(buf, 2, ExtendedTextMessage.ToByteArray());
+ ProtoEncoder.WriteMessage(buf, 6, ExtendedTextMessage.ToByteArray());
+ if (DocumentMessage != null) ProtoEncoder.WriteMessage(buf, 15, DocumentMessage.ToByteArray());
+ if (DeviceSentMessage != null)
+ ProtoEncoder.WriteMessage(buf, 31, DeviceSentMessage.ToByteArray());
return [.. buf];
}
@@ -168,29 +446,99 @@ public static WAMessage ParseFrom(byte[] data)
var (field, wire) = r.ReadTag();
switch (field)
{
- case 1:
- msg.Conversation = r.ReadString();
- break;
- case 2:
- msg.ExtendedTextMessage = ExtendedTextMessage.ParseFrom(r.ReadBytes());
- break;
- default:
- r.Skip(wire);
- break;
+ case 1: msg.Conversation = r.ReadString(); break;
+ case 2: msg.SenderKeyDist = SenderKeyDistributionMessage.ParseFrom(r.ReadBytes()); break;
+ case 3: msg.ImageMessage = ImageMessage.ParseFrom(r.ReadBytes()); break;
+ case 4: msg.AudioMessage = AudioMessage.ParseFrom(r.ReadBytes()); break;
+ case 5: msg.VideoMessage = VideoMessage.ParseFrom(r.ReadBytes()); break;
+ case 6: msg.ExtendedTextMessage = ExtendedTextMessage.ParseFrom(r.ReadBytes()); break;
+ case 12: msg.ProtocolMsg = ProtocolMessage.ParseFrom(r.ReadBytes()); break;
+ case 15: msg.DocumentMessage = DocumentMessage.ParseFrom(r.ReadBytes()); break;
+ case 50: msg.StickerMessage = StickerMessage.ParseFrom(r.ReadBytes()); break;
+ case 31: msg.DeviceSentMessage = DeviceSentMessage.ParseFrom(r.ReadBytes()); break;
+ case 35: msg.MessageContextInfo = MessageContextInfo.ParseFrom(r.ReadBytes()); break;
+ case 46: msg.HistorySyncNotification = HistorySyncNotification.Decode(r.ReadBytes()); break;
+ case 85: msg.ReactionMessage = ReactionMessage.ParseFrom(r.ReadBytes()); break;
+ case 145: msg.PeerDataOperation = null; r.Skip(wire); break; // outgoing only — skip
+ case 146: msg.PeerDataResponse = PeerDataOperationResponseMessage.Decode(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
}
}
return msg;
}
+
+ public byte[] ToByteArrayWithPeerDataOperation()
+ {
+ var buf = new List();
+ if (PeerDataOperation != null)
+ ProtoEncoder.WriteMessage(buf, 145, PeerDataOperation.ToByteArray());
+ return [.. buf];
+ }
+
+ public byte[] ToByteArrayWithReaction()
+ {
+ var buf = new List();
+ if (ReactionMessage != null)
+ ProtoEncoder.WriteMessage(buf, 85, ReactionMessage.ToByteArray());
+ return [.. buf];
+ }
+
+ public byte[] ToByteArrayWithRevoke()
+ {
+ var buf = new List();
+ if (ProtocolMsg != null)
+ ProtoEncoder.WriteMessage(buf, 12, ProtocolMsg.ToByteArray());
+ return [.. buf];
+ }
+
+ ///
+ /// Returns the ContextInfo (quoted message metadata) from whichever sub-message contains it.
+ ///
+ public ContextInfo? GetContextInfo()
+ {
+ var inner = DeviceSentMessage?.Message;
+ if (inner != null) return inner.GetContextInfo();
+ return ImageMessage?.ContextInfo
+ ?? AudioMessage?.ContextInfo
+ ?? VideoMessage?.ContextInfo
+ ?? ExtendedTextMessage?.ContextInfo;
+ }
+
+ ///
+ /// Returns quoted message context (id, sender, text preview, type) for reply display.
+ ///
+ public (string? quotedId, string? quotedFrom, string? quotedText, Messages.MessageType quotedType) GetQuotedContext()
+ {
+ var ctx = GetContextInfo();
+ if (ctx == null || string.IsNullOrEmpty(ctx.StanzaId))
+ return (null, null, null, Messages.MessageType.Unknown);
+
+ var quotedText = ctx.QuotedMessage?.GetText();
+ var quotedType = ctx.QuotedMessage?.GetMessageType() ?? Messages.MessageType.Unknown;
+ return (ctx.StanzaId, ctx.Participant, quotedText, quotedType);
+ }
+
+ public byte[] ToByteArrayWithMedia()
+ {
+ var buf = new List();
+ if (ImageMessage != null) ProtoEncoder.WriteMessage(buf, 3, ImageMessage.ToByteArray());
+ if (AudioMessage != null) ProtoEncoder.WriteMessage(buf, 4, AudioMessage.ToByteArray());
+ if (DocumentMessage != null) ProtoEncoder.WriteMessage(buf, 15, DocumentMessage.ToByteArray());
+ return [.. buf];
+ }
}
+/// Field 6 of Message. Contains text with optional link preview etc.
public sealed class ExtendedTextMessage
{
- public string Text { get; set; } = "";
+ public string Text { get; set; } = ""; // field 1
+ public ContextInfo? ContextInfo { get; set; } // field 17
public byte[] ToByteArray()
{
var buf = new List();
ProtoEncoder.WriteString(buf, 1, Text);
+ if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray());
return [.. buf];
}
@@ -201,9 +549,671 @@ public static ExtendedTextMessage ParseFrom(byte[] data)
while (r.HasMore)
{
var (field, wire) = r.ReadTag();
- if (field == 1) msg.Text = r.ReadString();
- else r.Skip(wire);
+ switch (field)
+ {
+ case 1: msg.Text = r.ReadString(); break;
+ case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 3 of Message. An image with optional caption.
+public sealed class ImageMessage
+{
+ public string Url { get; set; } = ""; // field 1
+ public string MimeType { get; set; } = ""; // field 2
+ public string Caption { get; set; } = ""; // field 3
+ public byte[] FileSha256 { get; set; } = []; // field 4
+ public ulong FileLength { get; set; } // field 5
+ public uint Height { get; set; } // field 6
+ public uint Width { get; set; } // field 7
+ public byte[] MediaKey { get; set; } = []; // field 8
+ public byte[] FileEncSha256 { get; set; } = []; // field 9
+ public string DirectPath { get; set; } = ""; // field 11
+ public long MediaKeyTimestamp { get; set; } // field 12
+ public byte[] JpegThumbnail { get; set; } = []; // field 16
+ public ContextInfo? ContextInfo { get; set; } // field 17
+ public bool ViewOnce { get; set; } // field 25
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteString(buf, 1, Url);
+ ProtoEncoder.WriteString(buf, 2, MimeType);
+ if (!string.IsNullOrEmpty(Caption)) ProtoEncoder.WriteString(buf, 3, Caption);
+ if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 4, FileSha256);
+ if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 5, FileLength);
+ if (Height > 0) ProtoEncoder.WriteUInt32(buf, 6, Height);
+ if (Width > 0) ProtoEncoder.WriteUInt32(buf, 7, Width);
+ if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 8, MediaKey);
+ if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 9, FileEncSha256);
+ ProtoEncoder.WriteString(buf, 11, DirectPath);
+ if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 12, (ulong)MediaKeyTimestamp);
+ if (JpegThumbnail.Length > 0) ProtoEncoder.WriteBytes(buf, 16, JpegThumbnail);
+ if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray());
+ return [.. buf];
+ }
+
+ public static ImageMessage ParseFrom(byte[] data)
+ {
+ var msg = new ImageMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Url = r.ReadString(); break;
+ case 2: msg.MimeType = r.ReadString(); break;
+ case 3: msg.Caption = r.ReadString(); break;
+ case 4: msg.FileSha256 = r.ReadBytes(); break;
+ case 5: msg.FileLength = r.ReadUInt64(); break;
+ case 6: msg.Height = r.ReadUInt32(); break;
+ case 7: msg.Width = r.ReadUInt32(); break;
+ case 8: msg.MediaKey = r.ReadBytes(); break;
+ case 9: msg.FileEncSha256 = r.ReadBytes(); break;
+ case 11: msg.DirectPath = r.ReadString(); break;
+ case 12: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break;
+ case 16: msg.JpegThumbnail = r.ReadBytes(); break;
+ case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break;
+ case 25: msg.ViewOnce = r.ReadUInt64() != 0; break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 4 of Message. An audio clip or voice note (ptt=true).
+public sealed class AudioMessage
+{
+ public string Url { get; set; } = ""; // field 1
+ public string MimeType { get; set; } = ""; // field 2
+ public byte[] FileSha256 { get; set; } = []; // field 3
+ public ulong FileLength { get; set; } // field 4
+ public uint Seconds { get; set; } // field 5 (duration)
+ public bool Ptt { get; set; } // field 6 (push-to-talk = voice note)
+ public byte[] MediaKey { get; set; } = []; // field 7
+ public byte[] FileEncSha256 { get; set; } = []; // field 8
+ public string DirectPath { get; set; } = ""; // field 9
+ public long MediaKeyTimestamp { get; set; } // field 12
+ public ContextInfo? ContextInfo { get; set; } // field 17
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteString(buf, 1, Url);
+ ProtoEncoder.WriteString(buf, 2, MimeType);
+ if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 3, FileSha256);
+ if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 4, FileLength);
+ if (Seconds > 0) ProtoEncoder.WriteUInt32(buf, 5, Seconds);
+ if (Ptt) ProtoEncoder.WriteBool(buf, 6, Ptt);
+ if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 7, MediaKey);
+ if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 8, FileEncSha256);
+ ProtoEncoder.WriteString(buf, 9, DirectPath);
+ if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 12, (ulong)MediaKeyTimestamp);
+ if (ContextInfo != null) ProtoEncoder.WriteMessage(buf, 17, ContextInfo.ToByteArray());
+ return [.. buf];
+ }
+
+ public static AudioMessage ParseFrom(byte[] data)
+ {
+ var msg = new AudioMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Url = r.ReadString(); break;
+ case 2: msg.MimeType = r.ReadString(); break;
+ case 3: msg.FileSha256 = r.ReadBytes(); break;
+ case 4: msg.FileLength = r.ReadUInt64(); break;
+ case 5: msg.Seconds = r.ReadUInt32(); break;
+ case 6: msg.Ptt = r.ReadBool(); break;
+ case 7: msg.MediaKey = r.ReadBytes(); break;
+ case 8: msg.FileEncSha256 = r.ReadBytes(); break;
+ case 9: msg.DirectPath = r.ReadString(); break;
+ case 12: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break;
+ case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 5 of Message. A video with optional caption.
+public sealed class VideoMessage
+{
+ public string Url { get; set; } = ""; // field 1
+ public string MimeType { get; set; } = ""; // field 2
+ public byte[] FileSha256 { get; set; } = []; // field 3
+ public ulong FileLength { get; set; } // field 4
+ public uint Seconds { get; set; } // field 5
+ public byte[] MediaKey { get; set; } = []; // field 6
+ public string Caption { get; set; } = ""; // field 7
+ public bool GifPlayback { get; set; } // field 8
+ public uint Height { get; set; } // field 9
+ public uint Width { get; set; } // field 10
+ public byte[] FileEncSha256 { get; set; } = []; // field 11
+ public string DirectPath { get; set; } = ""; // field 13
+ public long MediaKeyTimestamp { get; set; } // field 14
+ public ContextInfo? ContextInfo { get; set; } // field 17
+
+ public static VideoMessage ParseFrom(byte[] data)
+ {
+ var msg = new VideoMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Url = r.ReadString(); break;
+ case 2: msg.MimeType = r.ReadString(); break;
+ case 3: msg.FileSha256 = r.ReadBytes(); break;
+ case 4: msg.FileLength = r.ReadUInt64(); break;
+ case 5: msg.Seconds = r.ReadUInt32(); break;
+ case 6: msg.MediaKey = r.ReadBytes(); break;
+ case 7: msg.Caption = r.ReadString(); break;
+ case 8: msg.GifPlayback = r.ReadUInt64() != 0; break;
+ case 9: msg.Height = r.ReadUInt32(); break;
+ case 10: msg.Width = r.ReadUInt32(); break;
+ case 11: msg.FileEncSha256 = r.ReadBytes(); break;
+ case 13: msg.DirectPath = r.ReadString(); break;
+ case 14: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break;
+ case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 50 of Message. A sticker.
+public sealed class StickerMessage
+{
+ public string Url { get; set; } = ""; // field 1
+ public byte[] FileSha256 { get; set; } = []; // field 2
+ public byte[] FileEncSha256 { get; set; } = []; // field 3
+ public byte[] MediaKey { get; set; } = []; // field 4
+ public string MimeType { get; set; } = ""; // field 5
+ public uint Height { get; set; } // field 6
+ public uint Width { get; set; } // field 7
+ public string DirectPath { get; set; } = ""; // field 8
+ public ulong FileLength { get; set; } // field 9
+ public long MediaKeyTimestamp { get; set; } // field 10
+ public ContextInfo? ContextInfo { get; set; } // field 17
+
+ public static StickerMessage ParseFrom(byte[] data)
+ {
+ var msg = new StickerMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Url = r.ReadString(); break;
+ case 2: msg.FileSha256 = r.ReadBytes(); break;
+ case 3: msg.FileEncSha256 = r.ReadBytes(); break;
+ case 4: msg.MediaKey = r.ReadBytes(); break;
+ case 5: msg.MimeType = r.ReadString(); break;
+ case 6: msg.Height = r.ReadUInt32(); break;
+ case 7: msg.Width = r.ReadUInt32(); break;
+ case 8: msg.DirectPath = r.ReadString(); break;
+ case 9: msg.FileLength = r.ReadUInt64(); break;
+ case 10: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break;
+ case 17: msg.ContextInfo = ContextInfo.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
+ }
}
return msg;
}
}
+
+/// Field 15 of Message. A document/file attachment.
+public sealed class DocumentMessage
+{
+ public string Url { get; set; } = ""; // field 1
+ public string MimeType { get; set; } = ""; // field 2
+ public string Title { get; set; } = ""; // field 3
+ public byte[] FileSha256 { get; set; } = []; // field 4
+ public ulong FileLength { get; set; } // field 5
+ public byte[] MediaKey { get; set; } = []; // field 7
+ public string FileName { get; set; } = ""; // field 8
+ public byte[] FileEncSha256 { get; set; } = []; // field 9
+ public string DirectPath { get; set; } = ""; // field 10
+ public long MediaKeyTimestamp { get; set; } // field 11
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ ProtoEncoder.WriteString(buf, 1, Url);
+ ProtoEncoder.WriteString(buf, 2, MimeType);
+ ProtoEncoder.WriteString(buf, 3, Title);
+ if (FileSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 4, FileSha256);
+ if (FileLength > 0) ProtoEncoder.WriteUInt64(buf, 5, FileLength);
+ if (MediaKey.Length > 0) ProtoEncoder.WriteBytes(buf, 7, MediaKey);
+ ProtoEncoder.WriteString(buf, 8, FileName);
+ if (FileEncSha256.Length > 0) ProtoEncoder.WriteBytes(buf, 9, FileEncSha256);
+ ProtoEncoder.WriteString(buf, 10, DirectPath);
+ if (MediaKeyTimestamp != 0) ProtoEncoder.WriteUInt64(buf, 11, (ulong)MediaKeyTimestamp);
+ return [.. buf];
+ }
+
+ public static DocumentMessage ParseFrom(byte[] data)
+ {
+ var msg = new DocumentMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Url = r.ReadString(); break;
+ case 2: msg.MimeType = r.ReadString(); break;
+ case 3: msg.Title = r.ReadString(); break;
+ case 4: msg.FileSha256 = r.ReadBytes(); break;
+ case 5: msg.FileLength = r.ReadUInt64(); break;
+ case 7: msg.MediaKey = r.ReadBytes(); break;
+ case 8: msg.FileName = r.ReadString(); break;
+ case 9: msg.FileEncSha256 = r.ReadBytes(); break;
+ case 10: msg.DirectPath = r.ReadString(); break;
+ case 11: msg.MediaKeyTimestamp = (long)r.ReadUInt64(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 31 of Message. Wraps messages sent from this account on another device.
+public sealed class DeviceSentMessage
+{
+ public string? DestinationJid { get; set; } // field 1
+ public WAMessage? Message { get; set; } // field 2
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ if (!string.IsNullOrEmpty(DestinationJid))
+ ProtoEncoder.WriteString(buf, 1, DestinationJid);
+ if (Message != null)
+ ProtoEncoder.WriteMessage(buf, 2, Message.ToByteArray());
+ return [.. buf];
+ }
+
+ public static DeviceSentMessage ParseFrom(byte[] data)
+ {
+ var msg = new DeviceSentMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.DestinationJid = r.ReadString(); break;
+ case 2: msg.Message = WAMessage.ParseFrom(r.ReadBytes()); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 2 of Message. Distributes group encryption keys.
+public sealed class SenderKeyDistributionMessage
+{
+ public string? GroupId { get; set; } // field 1
+ public byte[] AxolotlSenderKeyData { get; set; } = []; // field 2
+
+ public static SenderKeyDistributionMessage ParseFrom(byte[] data)
+ {
+ var msg = new SenderKeyDistributionMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.GroupId = r.ReadString(); break;
+ case 2: msg.AxolotlSenderKeyData = r.ReadBytes(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 12 of Message. Protocol-level messages (message revocation, history sync, etc.)
+public sealed class ProtocolMessage
+{
+ public int Type { get; set; } // field 5: type enum
+ // field 1: key (MessageKey) — the message being revoked (for REVOKE)
+ public MessageKey? Key { get; set; }
+ // field 6: historySyncNotification (populated when Type == TYPE_HISTORY_SYNC_NOTIFICATION)
+ public Dawa.Proto.HistorySyncNotification? HistorySyncNotification { get; set; }
+
+ public const int TYPE_REVOKE = 0;
+ public const int TYPE_HISTORY_SYNC_NOTIFICATION = 5;
+
+ public static ProtocolMessage ParseFrom(byte[] data)
+ {
+ var msg = new ProtocolMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Key = MessageKey.ParseFrom(r.ReadBytes()); break;
+ case 5: msg.Type = r.ReadInt32(); break;
+ case 6:
+ var notifBytes = r.ReadBytes();
+ msg.HistorySyncNotification = Dawa.Proto.HistorySyncNotification.Decode(notifBytes);
+ break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ if (Key != null) ProtoEncoder.WriteMessage(buf, 1, Key.ToByteArray());
+ ProtoEncoder.WriteInt32(buf, 5, Type);
+ return [.. buf];
+ }
+}
+
+/// ContextInfo — quoted message metadata embedded in most message types (field 17).
+public sealed class ContextInfo
+{
+ public string StanzaId { get; set; } = ""; // field 4: ID of the quoted message
+ public string Participant { get; set; } = ""; // field 5: JID of the quoted message sender
+ public WAMessage? QuotedMessage { get; set; } // field 6: the quoted message content
+ public bool IsForwarded { get; set; } // field 15: forwarded flag
+ public uint ForwardingScore { get; set; } // field 22: >0 means forwarded
+
+ public static ContextInfo ParseFrom(byte[] data)
+ {
+ var msg = new ContextInfo();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 4: msg.StanzaId = r.ReadString(); break;
+ case 5: msg.Participant = r.ReadString(); break;
+ case 6: msg.QuotedMessage = WAMessage.ParseFrom(r.ReadBytes()); break;
+ case 15: msg.IsForwarded = r.ReadBool(); break;
+ case 22: msg.ForwardingScore = r.ReadUInt32(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ if (!string.IsNullOrEmpty(StanzaId)) ProtoEncoder.WriteString(buf, 4, StanzaId);
+ if (!string.IsNullOrEmpty(Participant)) ProtoEncoder.WriteString(buf, 5, Participant);
+ if (QuotedMessage != null) ProtoEncoder.WriteMessage(buf, 6, QuotedMessage.ToByteArray());
+ if (IsForwarded) ProtoEncoder.WriteBool(buf, 15, true);
+ if (ForwardingScore > 0) ProtoEncoder.WriteUInt32(buf, 22, ForwardingScore);
+ return [.. buf];
+ }
+}
+
+/// A single message inside a HistorySyncConversation.
+public sealed class WebMessageInfo
+{
+ public WebMessageKey? Key { get; set; } // field 1
+ public WAMessage? Message { get; set; } // field 2
+ public ulong MessageTimestamp { get; set; } // field 3
+ public int Status { get; set; } // field 4 (delivery status)
+ public string PushName { get; set; } = ""; // field 5
+
+ public static WebMessageInfo ParseFrom(byte[] data)
+ {
+ var msg = new WebMessageInfo();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Key = WebMessageKey.ParseFrom(r.ReadBytes()); break;
+ case 2: msg.Message = WAMessage.ParseFrom(r.ReadBytes()); break;
+ case 3: msg.MessageTimestamp = r.ReadUInt64(); break;
+ case 4: msg.Status = r.ReadInt32(); break;
+ case 5: msg.PushName = r.ReadString(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Message key inside WebMessageInfo.
+public sealed class WebMessageKey
+{
+ public string RemoteJid { get; set; } = ""; // field 1
+ public bool FromMe { get; set; } // field 2
+ public string Id { get; set; } = ""; // field 3
+ public string Participant { get; set; } = ""; // field 4 (group sender)
+
+ public static WebMessageKey ParseFrom(byte[] data)
+ {
+ var msg = new WebMessageKey();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.RemoteJid = r.ReadString(); break;
+ case 2: msg.FromMe = r.ReadBool(); break;
+ case 3: msg.Id = r.ReadString(); break;
+ case 4: msg.Participant = r.ReadString(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 35 of Message. Contains device list metadata.
+public sealed class MessageContextInfo
+{
+ public byte[] DeviceListMetadata { get; set; } = []; // field 1
+
+ public static MessageContextInfo ParseFrom(byte[] data)
+ {
+ var msg = new MessageContextInfo();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.DeviceListMetadata = r.ReadBytes(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+}
+
+/// Field 85 of Message. Emoji reaction to another message.
+public sealed class ReactionMessage
+{
+ /// The message being reacted to.
+ public MessageKey? Key { get; set; } // field 1
+ /// The reaction emoji (e.g. "👍") or "" to remove reaction.
+ public string Text { get; set; } = ""; // field 2
+ /// Sender timestamp in milliseconds.
+ public long SenderTimestampMs { get; set; } // field 4
+
+ public static ReactionMessage ParseFrom(byte[] data)
+ {
+ var msg = new ReactionMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.Key = MessageKey.ParseFrom(r.ReadBytes()); break;
+ case 2: msg.Text = r.ReadString(); break;
+ case 4: msg.SenderTimestampMs = (long)r.ReadUInt64(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ if (Key != null) ProtoEncoder.WriteMessage(buf, 1, Key.ToByteArray());
+ if (!string.IsNullOrEmpty(Text)) ProtoEncoder.WriteString(buf, 2, Text);
+ if (SenderTimestampMs != 0) ProtoEncoder.WriteUInt64(buf, 4, (ulong)SenderTimestampMs);
+ return [.. buf];
+ }
+}
+
+///
+/// Field 145 of WAMessage.
+/// Sent to our own JID to request the phone push an ON_DEMAND history sync blob
+/// for a specific chat via HISTORY_SYNC_NOTIFICATION (syncType 5).
+///
+public sealed class PeerDataOperationRequestMessage
+{
+ public const int TYPE_HISTORY_SYNC_ON_DEMAND = 7; // PeerDataOperationRequestType enum
+
+ public int RequestType { get; set; } = TYPE_HISTORY_SYNC_ON_DEMAND; // field 1
+ public string ChatJid { get; set; } = ""; // historySyncOnDemandRequest.chatJid (field 6 → sub-field 1)
+ public string OldestMsgId { get; set; } = ""; // historySyncOnDemandRequest.oldestMsgId (field 6 → sub-field 2)
+ public bool OldestMsgFromMe { get; set; } // historySyncOnDemandRequest.oldestMsgFromMe (field 6 → sub-field 3)
+ public int OnDemandMsgCount { get; set; } = 50; // historySyncOnDemandRequest.onDemandMsgCount (field 6 → sub-field 4)
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ // field 1: peerDataOperationRequestType
+ ProtoEncoder.WriteInt32(buf, 1, RequestType);
+
+ // field 6: historySyncOnDemandRequest (embedded message)
+ var req = new List();
+ ProtoEncoder.WriteString(req, 1, ChatJid);
+ if (!string.IsNullOrEmpty(OldestMsgId)) ProtoEncoder.WriteString(req, 2, OldestMsgId);
+ if (OldestMsgFromMe) ProtoEncoder.WriteBool(req, 3, true);
+ ProtoEncoder.WriteInt32Always(req, 4, OnDemandMsgCount); // always emit count even if 0
+ ProtoEncoder.WriteMessage(buf, 6, [.. req]);
+
+ return [.. buf];
+ }
+}
+
+///
+/// Field 146 of WAMessage — response sent by the phone to a PeerDataOperationRequestMessage.
+/// For ON_DEMAND history, historySyncOnDemandRequestResult.historyData contains
+/// an inline compressed+encrypted history blob (same format as CDN download).
+///
+public sealed class PeerDataOperationResponseMessage
+{
+ /// List of results, one per request item.
+ public List Results { get; set; } = [];
+
+ public static PeerDataOperationResponseMessage Decode(byte[] data)
+ {
+ var obj = new PeerDataOperationResponseMessage();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ if (field == 1) obj.Results.Add(PeerDataOperationResult.Decode(r.ReadBytes()));
+ else r.Skip(wt);
+ }
+ return obj;
+ }
+}
+
+public sealed class PeerDataOperationResult
+{
+ public int ResultType { get; set; } // field 1: 0=OK, 1=UNSUPPORTED, 2=NOT_FOUND
+ public byte[] HistoryData { get; set; } = []; // field 6 → historySyncOnDemandRequestResult.historyData (sub-field 1)
+
+ public static PeerDataOperationResult Decode(byte[] data)
+ {
+ var obj = new PeerDataOperationResult();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wt) = r.ReadTag();
+ switch (field)
+ {
+ case 1: obj.ResultType = r.ReadInt32(); break;
+ case 6: obj.HistoryData = ReadHistoryData(r.ReadBytes()); break;
+ default: r.Skip(wt); break;
+ }
+ }
+ return obj;
+ }
+
+ private static byte[] ReadHistoryData(byte[] resultBytes)
+ {
+ var r = ProtoEncoder.CreateReader(resultBytes);
+ while (r.HasMore)
+ {
+ var (f, wt) = r.ReadTag();
+ if (f == 1) return r.ReadBytes();
+ r.Skip(wt);
+ }
+ return [];
+ }
+}
+
+/// Key that identifies a specific WhatsApp message.
+public sealed class MessageKey
+{
+ public string RemoteJid { get; set; } = ""; // field 1
+ public bool FromMe { get; set; } // field 2
+ public string Id { get; set; } = ""; // field 3
+
+ public static MessageKey ParseFrom(byte[] data)
+ {
+ var msg = new MessageKey();
+ var r = ProtoEncoder.CreateReader(data);
+ while (r.HasMore)
+ {
+ var (field, wire) = r.ReadTag();
+ switch (field)
+ {
+ case 1: msg.RemoteJid = r.ReadString(); break;
+ case 2: msg.FromMe = r.ReadBool(); break;
+ case 3: msg.Id = r.ReadString(); break;
+ default: r.Skip(wire); break;
+ }
+ }
+ return msg;
+ }
+
+ public byte[] ToByteArray()
+ {
+ var buf = new List();
+ if (!string.IsNullOrEmpty(RemoteJid)) ProtoEncoder.WriteString(buf, 1, RemoteJid);
+ if (FromMe) ProtoEncoder.WriteBool(buf, 2, FromMe);
+ if (!string.IsNullOrEmpty(Id)) ProtoEncoder.WriteString(buf, 3, Id);
+ return [.. buf];
+ }
+}
diff --git a/src/Dawa/Signal/LID_SUPPORT_DESIGN.md b/src/Dawa/Signal/LID_SUPPORT_DESIGN.md
new file mode 100644
index 0000000..50d91d0
--- /dev/null
+++ b/src/Dawa/Signal/LID_SUPPORT_DESIGN.md
@@ -0,0 +1,118 @@
+# Dawa LID Support — Root Cause & Fix Design (2026-07-03)
+
+## Problem
+
+Incoming message decryption fails ~50% of the time (1790 MAC OK vs 1688 MAC FAIL in the
+production log tail), concentrated on `@lid` (WhatsApp LinkedID) addresses. Effect: since
+~2026-06-05 almost no new incoming messages reach the message-store, so team replies are
+invisible to jengo-agi/jengo-web. Outgoing works fine.
+
+## Background: what LID is
+
+WhatsApp is moving the identity of a contact from the phone number
+(`@s.whatsapp.net`, "PN") to a stable Linked-ID (`@lid`, "LID"). The two
+JIDs refer to the **same user and same devices** but are different users in the protocol
+namespace. The device id carries through unchanged across namespaces
+(`:@s.whatsapp.net` ↔ `: