whalibmob 5.22.0 → 5.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -110
- package/lib/Client.js +90 -1
- package/lib/Registration.js +23 -2
- package/lib/messages/MediaRetry.js +228 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,6 +181,7 @@ npm install -g whalibmob
|
|
|
181
181
|
- [When 463 Means the Account Is Restricted](#when-463-means-the-account-is-restricted)
|
|
182
182
|
- [What Is Automatic vs What You Need to Do](#what-is-automatic-vs-what-you-need-to-do)
|
|
183
183
|
- [Receiving Media](#receiving-media)
|
|
184
|
+
- [When the File Is Gone from the CDN](#when-the-file-is-gone-from-the-cdn)
|
|
184
185
|
- [Sending Messages](#sending-messages)
|
|
185
186
|
- [Text Message](#text-message)
|
|
186
187
|
- [Quote Message](#quote-message)
|
|
@@ -573,7 +574,7 @@ After running `wa connect <phone>`, every feature of the library is available as
|
|
|
573
574
|
#### Send Text
|
|
574
575
|
|
|
575
576
|
```sh
|
|
576
|
-
wa> /send 919634847671
|
|
577
|
+
wa> /send 919634847671 Hello, how are you?
|
|
577
578
|
sent 3EB0ABCDEF123456
|
|
578
579
|
|
|
579
580
|
# to a group
|
|
@@ -583,8 +584,8 @@ wa> /send 120363000000000000@g.us Hello everyone!
|
|
|
583
584
|
#### Send Image
|
|
584
585
|
|
|
585
586
|
```sh
|
|
586
|
-
wa> /image 919634847671
|
|
587
|
-
wa> /image 919634847671
|
|
587
|
+
wa> /image 919634847671 ./photo.jpg
|
|
588
|
+
wa> /image 919634847671 ./photo.jpg Look at this!
|
|
588
589
|
```
|
|
589
590
|
|
|
590
591
|
The second argument is the file path. The optional third argument is the caption.
|
|
@@ -592,8 +593,8 @@ The second argument is the file path. The optional third argument is the caption
|
|
|
592
593
|
#### Send Video
|
|
593
594
|
|
|
594
595
|
```sh
|
|
595
|
-
wa> /video 919634847671
|
|
596
|
-
wa> /video 919634847671
|
|
596
|
+
wa> /video 919634847671 ./clip.mp4
|
|
597
|
+
wa> /video 919634847671 ./clip.mp4 Watch this
|
|
597
598
|
```
|
|
598
599
|
|
|
599
600
|
#### Send Audio
|
|
@@ -601,7 +602,7 @@ wa> /video 919634847671@s.whatsapp.net ./clip.mp4 Watch this
|
|
|
601
602
|
Sends the file as a regular audio attachment:
|
|
602
603
|
|
|
603
604
|
```sh
|
|
604
|
-
wa> /audio 919634847671
|
|
605
|
+
wa> /audio 919634847671 ./song.mp3
|
|
605
606
|
```
|
|
606
607
|
|
|
607
608
|
#### Send Voice Note
|
|
@@ -609,14 +610,14 @@ wa> /audio 919634847671@s.whatsapp.net ./song.mp3
|
|
|
609
610
|
Sends the file as a push-to-talk voice note with waveform:
|
|
610
611
|
|
|
611
612
|
```sh
|
|
612
|
-
wa> /ptt 919634847671
|
|
613
|
+
wa> /ptt 919634847671 ./voice.ogg
|
|
613
614
|
```
|
|
614
615
|
|
|
615
616
|
#### Send Document
|
|
616
617
|
|
|
617
618
|
```sh
|
|
618
|
-
wa> /doc 919634847671
|
|
619
|
-
wa> /doc 919634847671
|
|
619
|
+
wa> /doc 919634847671 ./report.pdf
|
|
620
|
+
wa> /doc 919634847671 ./report.pdf "Q1 Report.pdf"
|
|
620
621
|
```
|
|
621
622
|
|
|
622
623
|
The optional third argument overrides the displayed filename.
|
|
@@ -626,7 +627,7 @@ The optional third argument overrides the displayed filename.
|
|
|
626
627
|
The file must be in WebP format:
|
|
627
628
|
|
|
628
629
|
```sh
|
|
629
|
-
wa> /sticker 919634847671
|
|
630
|
+
wa> /sticker 919634847671 ./sticker.webp
|
|
630
631
|
```
|
|
631
632
|
|
|
632
633
|
#### Send Poll (CLI)
|
|
@@ -635,7 +636,7 @@ Separate the question from the options using `|`. At least two options are requi
|
|
|
635
636
|
|
|
636
637
|
```sh
|
|
637
638
|
# single-choice poll (selectable=1)
|
|
638
|
-
wa> /poll 919634847671
|
|
639
|
+
wa> /poll 919634847671 Best language? | JavaScript | Python | Rust | selectable=1
|
|
639
640
|
|
|
640
641
|
# unlimited-choice poll (default)
|
|
641
642
|
wa> /poll 120363000000000000@g.us Pick your favourites | Red | Green | Blue
|
|
@@ -644,10 +645,10 @@ wa> /poll 120363000000000000@g.us Pick your favourites | Red | Green | Blue
|
|
|
644
645
|
#### React to a Message
|
|
645
646
|
|
|
646
647
|
```sh
|
|
647
|
-
wa> /react 919634847671
|
|
648
|
+
wa> /react 919634847671 3EB0ABCDEF123456 👍
|
|
648
649
|
|
|
649
650
|
# remove a reaction — pass a space or empty string
|
|
650
|
-
wa> /react 919634847671
|
|
651
|
+
wa> /react 919634847671 3EB0ABCDEF123456 " "
|
|
651
652
|
```
|
|
652
653
|
|
|
653
654
|
The message ID is shown in the incoming message display as `id`.
|
|
@@ -658,17 +659,17 @@ The message ID is shown in the incoming message display as `id`.
|
|
|
658
659
|
> Editing is only possible within 15 minutes of the original send.
|
|
659
660
|
|
|
660
661
|
```sh
|
|
661
|
-
wa> /edit 919634847671
|
|
662
|
+
wa> /edit 919634847671 3EB0ABCDEF123456 Corrected text here
|
|
662
663
|
```
|
|
663
664
|
|
|
664
665
|
#### Delete a Message
|
|
665
666
|
|
|
666
667
|
```sh
|
|
667
668
|
# delete for yourself only
|
|
668
|
-
wa> /delete 919634847671
|
|
669
|
+
wa> /delete 919634847671 3EB0ABCDEF123456
|
|
669
670
|
|
|
670
671
|
# delete for everyone (revoke)
|
|
671
|
-
wa> /delete 919634847671
|
|
672
|
+
wa> /delete 919634847671 3EB0ABCDEF123456 all
|
|
672
673
|
```
|
|
673
674
|
|
|
674
675
|
#### Post a Status / Story
|
|
@@ -684,7 +685,7 @@ wa> /status Good morning everyone!
|
|
|
684
685
|
Sends a message with the forwarded flag set:
|
|
685
686
|
|
|
686
687
|
```sh
|
|
687
|
-
wa> /forward 919634847671
|
|
688
|
+
wa> /forward 919634847671 This message was forwarded
|
|
688
689
|
```
|
|
689
690
|
|
|
690
691
|
#### Reply to a Message (CLI)
|
|
@@ -693,10 +694,10 @@ Quote and reply to a specific message. You need the message ID (shown as `id:` i
|
|
|
693
694
|
|
|
694
695
|
```sh
|
|
695
696
|
# DM — senderJid is the same as the chat JID
|
|
696
|
-
wa> /reply 919634847671
|
|
697
|
+
wa> /reply 919634847671 3EB0XXXXXXXX 919634847671 Got it, thanks!
|
|
697
698
|
|
|
698
699
|
# Group — senderJid is the member who sent the original message
|
|
699
|
-
wa> /reply 120363000000000000@g.us 3EB0XXXXXXXX 919634847671
|
|
700
|
+
wa> /reply 120363000000000000@g.us 3EB0XXXXXXXX 919634847671 Agreed!
|
|
700
701
|
```
|
|
701
702
|
|
|
702
703
|
The message ID is printed when a message arrives:
|
|
@@ -710,13 +711,13 @@ Send a GPS location pin. Latitude and longitude are required; name and address (
|
|
|
710
711
|
|
|
711
712
|
```sh
|
|
712
713
|
# lat/lon only
|
|
713
|
-
wa> /location 919634847671
|
|
714
|
+
wa> /location 919634847671 48.8566 2.3522
|
|
714
715
|
|
|
715
716
|
# with name
|
|
716
|
-
wa> /location 919634847671
|
|
717
|
+
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower
|
|
717
718
|
|
|
718
719
|
# with name and address (separate with |)
|
|
719
|
-
wa> /location 919634847671
|
|
720
|
+
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower | Champ de Mars, Paris
|
|
720
721
|
|
|
721
722
|
# to a group
|
|
722
723
|
wa> /location 120363000000000000@g.us 51.5074 -0.1278 London
|
|
@@ -727,7 +728,7 @@ wa> /location 120363000000000000@g.us 51.5074 -0.1278 London
|
|
|
727
728
|
Send a contact card. The vCard string must follow the vCard v3 format. Wrap it in quotes in the shell:
|
|
728
729
|
|
|
729
730
|
```sh
|
|
730
|
-
wa> /vcard 919634847671
|
|
731
|
+
wa> /vcard 919634847671 "Alice Smith" "BEGIN:VCARD\nVERSION:3.0\nFN:Alice Smith\nTEL;TYPE=CELL:+919634847671\nEND:VCARD"
|
|
731
732
|
```
|
|
732
733
|
|
|
733
734
|
For multi-line vCards it is easiest to store the string in a shell variable:
|
|
@@ -740,7 +741,7 @@ TEL;TYPE=CELL:+919634847671
|
|
|
740
741
|
EMAIL:alice@example.com
|
|
741
742
|
END:VCARD"
|
|
742
743
|
|
|
743
|
-
wa> /vcard 919634847671
|
|
744
|
+
wa> /vcard 919634847671 "Alice Smith" "$VCARD"
|
|
744
745
|
```
|
|
745
746
|
|
|
746
747
|
---
|
|
@@ -758,13 +759,13 @@ wa> /offline
|
|
|
758
759
|
|
|
759
760
|
```sh
|
|
760
761
|
# show "typing…" in a chat
|
|
761
|
-
wa> /typing 919634847671
|
|
762
|
+
wa> /typing 919634847671
|
|
762
763
|
|
|
763
764
|
# show "recording audio…" in a chat
|
|
764
|
-
wa> /recording 919634847671
|
|
765
|
+
wa> /recording 919634847671
|
|
765
766
|
|
|
766
767
|
# stop the indicator
|
|
767
|
-
wa> /stop 919634847671
|
|
768
|
+
wa> /stop 919634847671
|
|
768
769
|
```
|
|
769
770
|
|
|
770
771
|
#### Subscribe to a Contact's Presence
|
|
@@ -772,7 +773,7 @@ wa> /stop 919634847671@s.whatsapp.net
|
|
|
772
773
|
Subscribes to online/offline events for a contact. The shell will print presence updates as they arrive:
|
|
773
774
|
|
|
774
775
|
```sh
|
|
775
|
-
wa> /subscribe 919634847671
|
|
776
|
+
wa> /subscribe 919634847671
|
|
776
777
|
subscribed to 919634847671@s.whatsapp.net
|
|
777
778
|
|
|
778
779
|
# when they come online:
|
|
@@ -852,7 +853,7 @@ wa> /whatsapp 919634847671 12345678901
|
|
|
852
853
|
Returns the CDN URL for a contact's or group's profile picture:
|
|
853
854
|
|
|
854
855
|
```sh
|
|
855
|
-
wa> /picture 919634847671
|
|
856
|
+
wa> /picture 919634847671
|
|
856
857
|
https://mmg.whatsapp.net/v/...
|
|
857
858
|
|
|
858
859
|
wa> /picture 120363000000000000@g.us
|
|
@@ -864,7 +865,7 @@ wa> /picture 120363000000000000@g.us
|
|
|
864
865
|
Fetches the bio / about text for a contact:
|
|
865
866
|
|
|
866
867
|
```sh
|
|
867
|
-
wa> /contact about 919634847671
|
|
868
|
+
wa> /contact about 919634847671
|
|
868
869
|
Available 24/7
|
|
869
870
|
```
|
|
870
871
|
|
|
@@ -881,42 +882,42 @@ send the request a primary makes for itself; `/pin`, `/archive` and `/star`
|
|
|
881
882
|
update this session only. The command says which happened:
|
|
882
883
|
|
|
883
884
|
```sh
|
|
884
|
-
wa> /pin 919634847671
|
|
885
|
+
wa> /pin 919634847671
|
|
885
886
|
pinned (this session only — no app state key)
|
|
886
887
|
```
|
|
887
888
|
|
|
888
889
|
#### Mark Read / Unread
|
|
889
890
|
|
|
890
891
|
```sh
|
|
891
|
-
wa> /read 919634847671
|
|
892
|
-
wa> /unread 919634847671
|
|
892
|
+
wa> /read 919634847671
|
|
893
|
+
wa> /unread 919634847671
|
|
893
894
|
```
|
|
894
895
|
|
|
895
896
|
#### Mute / Unmute
|
|
896
897
|
|
|
897
898
|
```sh
|
|
898
899
|
# mute for 60 minutes
|
|
899
|
-
wa> /mute 919634847671
|
|
900
|
+
wa> /mute 919634847671 60
|
|
900
901
|
|
|
901
902
|
# mute indefinitely
|
|
902
|
-
wa> /mute 919634847671
|
|
903
|
+
wa> /mute 919634847671
|
|
903
904
|
|
|
904
905
|
# unmute
|
|
905
|
-
wa> /unmute 919634847671
|
|
906
|
+
wa> /unmute 919634847671
|
|
906
907
|
```
|
|
907
908
|
|
|
908
909
|
#### Pin / Unpin
|
|
909
910
|
|
|
910
911
|
```sh
|
|
911
|
-
wa> /pin 919634847671
|
|
912
|
-
wa> /unpin 919634847671
|
|
912
|
+
wa> /pin 919634847671
|
|
913
|
+
wa> /unpin 919634847671
|
|
913
914
|
```
|
|
914
915
|
|
|
915
916
|
#### Archive / Unarchive
|
|
916
917
|
|
|
917
918
|
```sh
|
|
918
|
-
wa> /archive 919634847671
|
|
919
|
-
wa> /unarchive 919634847671
|
|
919
|
+
wa> /archive 919634847671
|
|
920
|
+
wa> /unarchive 919634847671
|
|
920
921
|
```
|
|
921
922
|
|
|
922
923
|
#### Star / Unstar a Message (CLI)
|
|
@@ -925,8 +926,8 @@ Add `me` when the message is one you sent — it is part of how the star is file
|
|
|
925
926
|
so leaving it off on your own message stars the wrong thing.
|
|
926
927
|
|
|
927
928
|
```sh
|
|
928
|
-
wa> /star 919634847671
|
|
929
|
-
wa> /unstar 919634847671
|
|
929
|
+
wa> /star 919634847671 3EB0ABCDEF123456 me
|
|
930
|
+
wa> /unstar 919634847671 3EB0ABCDEF123456
|
|
930
931
|
```
|
|
931
932
|
|
|
932
933
|
<a id="cli-app-state"></a>
|
|
@@ -1051,13 +1052,13 @@ shell prints the change as it happens:
|
|
|
1051
1052
|
|
|
1052
1053
|
```sh
|
|
1053
1054
|
# set 1-day timer on a DM
|
|
1054
|
-
wa> /ephemeral 919634847671
|
|
1055
|
+
wa> /ephemeral 919634847671 86400
|
|
1055
1056
|
|
|
1056
1057
|
# set 1-week timer on a group
|
|
1057
1058
|
wa> /ephemeral 120363000000000000@g.us 604800
|
|
1058
1059
|
|
|
1059
1060
|
# turn off
|
|
1060
|
-
wa> /ephemeral 919634847671
|
|
1061
|
+
wa> /ephemeral 919634847671 0
|
|
1061
1062
|
```
|
|
1062
1063
|
|
|
1063
1064
|
#### Default Disappearing Timer
|
|
@@ -1078,10 +1079,10 @@ Accepts the same values as `/ephemeral`: 0, 86400, 604800, 7776000.
|
|
|
1078
1079
|
#### Block / Unblock
|
|
1079
1080
|
|
|
1080
1081
|
```sh
|
|
1081
|
-
wa> /block 919634847671
|
|
1082
|
+
wa> /block 919634847671
|
|
1082
1083
|
blocked 919634847671@s.whatsapp.net
|
|
1083
1084
|
|
|
1084
|
-
wa> /unblock 919634847671
|
|
1085
|
+
wa> /unblock 919634847671
|
|
1085
1086
|
unblocked 919634847671@s.whatsapp.net
|
|
1086
1087
|
```
|
|
1087
1088
|
|
|
@@ -1101,7 +1102,7 @@ wa> /blocklist
|
|
|
1101
1102
|
#### CLI Create a Group
|
|
1102
1103
|
|
|
1103
1104
|
```sh
|
|
1104
|
-
wa> /group create MyGroup 919634847671
|
|
1105
|
+
wa> /group create MyGroup 919634847671 12345678901
|
|
1105
1106
|
creating group...
|
|
1106
1107
|
created 120363000000000000@g.us
|
|
1107
1108
|
subject MyGroup
|
|
@@ -1119,12 +1120,12 @@ left 120363000000000000@g.us
|
|
|
1119
1120
|
|
|
1120
1121
|
```sh
|
|
1121
1122
|
# add participants
|
|
1122
|
-
wa> /group add 120363000000000000@g.us 919634847671
|
|
1123
|
+
wa> /group add 120363000000000000@g.us 919634847671 12345678901
|
|
1123
1124
|
added 919634847671@s.whatsapp.net
|
|
1124
1125
|
failed 12345678901@s.whatsapp.net — their privacy settings do not allow it (403) · can be invited instead
|
|
1125
1126
|
|
|
1126
1127
|
# remove participants
|
|
1127
|
-
wa> /group remove 120363000000000000@g.us 919634847671
|
|
1128
|
+
wa> /group remove 120363000000000000@g.us 919634847671
|
|
1128
1129
|
removed 919634847671@s.whatsapp.net
|
|
1129
1130
|
```
|
|
1130
1131
|
|
|
@@ -1135,10 +1136,10 @@ reported on its own line, because the server decides each one separately.
|
|
|
1135
1136
|
|
|
1136
1137
|
```sh
|
|
1137
1138
|
# promote to admin
|
|
1138
|
-
wa> /group promote 120363000000000000@g.us 919634847671
|
|
1139
|
+
wa> /group promote 120363000000000000@g.us 919634847671
|
|
1139
1140
|
|
|
1140
1141
|
# demote from admin
|
|
1141
|
-
wa> /group demote 120363000000000000@g.us 919634847671
|
|
1142
|
+
wa> /group demote 120363000000000000@g.us 919634847671
|
|
1142
1143
|
```
|
|
1143
1144
|
|
|
1144
1145
|
#### Change Group Name
|
|
@@ -1285,11 +1286,11 @@ wa> /group pending 120363000000000000@g.us
|
|
|
1285
1286
|
|
|
1286
1287
|
```sh
|
|
1287
1288
|
# approve one or more pending members
|
|
1288
|
-
wa> /group approve 120363000000000000@g.us 919634847671
|
|
1289
|
+
wa> /group approve 120363000000000000@g.us 919634847671
|
|
1289
1290
|
approved 919634847671@s.whatsapp.net
|
|
1290
1291
|
|
|
1291
1292
|
# reject one or more pending members
|
|
1292
|
-
wa> /group reject 120363000000000000@g.us 919634847671
|
|
1293
|
+
wa> /group reject 120363000000000000@g.us 919634847671
|
|
1293
1294
|
rejected 919634847671@s.whatsapp.net
|
|
1294
1295
|
```
|
|
1295
1296
|
|
|
@@ -1304,7 +1305,7 @@ For someone whose privacy settings do not let them be added to a group directly,
|
|
|
1304
1305
|
`add-invite` adds whoever it can and sends the rest a personal invitation:
|
|
1305
1306
|
|
|
1306
1307
|
```sh
|
|
1307
|
-
wa> /group add-invite 120363000000000000@g.us 919634847671
|
|
1308
|
+
wa> /group add-invite 120363000000000000@g.us 919634847671 12345678901
|
|
1308
1309
|
added 919634847671@s.whatsapp.net
|
|
1309
1310
|
failed 12345678901@s.whatsapp.net — their privacy settings do not allow it (403) · can be invited instead · invitation sent
|
|
1310
1311
|
```
|
|
@@ -1320,17 +1321,17 @@ An invitation that arrives for you shows the command that accepts it:
|
|
|
1320
1321
|
|
|
1321
1322
|
```sh
|
|
1322
1323
|
# look at the group without joining it
|
|
1323
|
-
wa> /group preview-invite 120363000000000000@g.us 919634847671
|
|
1324
|
+
wa> /group preview-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000
|
|
1324
1325
|
|
|
1325
1326
|
# join
|
|
1326
|
-
wa> /group accept-invite 120363000000000000@g.us 919634847671
|
|
1327
|
+
wa> /group accept-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000
|
|
1327
1328
|
joined 120363000000000000@g.us
|
|
1328
1329
|
|
|
1329
1330
|
# send one by hand
|
|
1330
|
-
wa> /group send-invite 120363000000000000@g.us 12345678901
|
|
1331
|
+
wa> /group send-invite 120363000000000000@g.us 12345678901 AbCdEfGh 1790000000
|
|
1331
1332
|
|
|
1332
1333
|
# take one back before it is used
|
|
1333
|
-
wa> /group revoke-invite 120363000000000000@g.us 12345678901
|
|
1334
|
+
wa> /group revoke-invite 120363000000000000@g.us 12345678901
|
|
1334
1335
|
```
|
|
1335
1336
|
|
|
1336
1337
|
#### Group Settings
|
|
@@ -1413,7 +1414,7 @@ wa> /newsletter post 120363000000000004@newsletter Breaking: WhatsApp adds polls
|
|
|
1413
1414
|
Query the public business profile of any WhatsApp Business account:
|
|
1414
1415
|
|
|
1415
1416
|
```sh
|
|
1416
|
-
wa> /biz 919634847671
|
|
1417
|
+
wa> /biz 919634847671
|
|
1417
1418
|
────────────────────────────────────────────────────────
|
|
1418
1419
|
jid 919634847671@s.whatsapp.net
|
|
1419
1420
|
category Software & IT Services
|
|
@@ -2162,7 +2163,7 @@ Everything after the scan is identical to the pairing-code path: `paired`, a str
|
|
|
2162
2163
|
A few seconds after the code is accepted (or the QR is scanned) you will see `paired`, the server restarts the stream, and `connected` fires on the new connection. From that point on everything else in this document applies unchanged:
|
|
2163
2164
|
|
|
2164
2165
|
```js
|
|
2165
|
-
await client.sendText('919876543210
|
|
2166
|
+
await client.sendText('919876543210', 'sent from a linked device')
|
|
2166
2167
|
```
|
|
2167
2168
|
|
|
2168
2169
|
### Reconnecting a Linked Session
|
|
@@ -2409,6 +2410,7 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
|
|
|
2409
2410
|
| `pair_device` | `{ refs }` | the QR path produced reference strings |
|
|
2410
2411
|
| `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
|
|
2411
2412
|
| `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
|
|
2413
|
+
| `media_retry` | `{ messageId, chatJid, fromMe, participant, ciphertext, iv, error }` | the sender's phone answered a `requestMediaRetry()`. Read it with `decryptMediaRetry()` and the original media key; an `error` instead of a payload means the phone no longer has the file either |
|
|
2412
2414
|
| `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Fires whether the refusal arrives during the handshake or once the stream is open, and the client stops retrying either way. Distinct from `auth_failure`, and there is nothing to re-pair. |
|
|
2413
2415
|
| `version_update` | `{ from, to, source }` | the announced version was refreshed from the platform's store before a handshake. Fires on reconnects as well as the first connect |
|
|
2414
2416
|
| `apk_material_stale` | `{ materialVersion, liveVersion, hint }` | Android only: the Play Store listing has moved past the APK the cached token material came from. Nothing is broken — registration is what reads that material — but the next number registered from this install would go out under an older build |
|
|
@@ -3129,7 +3131,7 @@ async function connect() {
|
|
|
3129
3131
|
|
|
3130
3132
|
client.on('connected', async () => {
|
|
3131
3133
|
console.log('connected')
|
|
3132
|
-
await client.sendText('919634847671
|
|
3134
|
+
await client.sendText('919634847671', 'Hello!')
|
|
3133
3135
|
})
|
|
3134
3136
|
|
|
3135
3137
|
client.on('disconnected', () => {
|
|
@@ -3663,12 +3665,56 @@ const bytes = await client.downloadMedia(d, { verify: true })
|
|
|
3663
3665
|
with no media, no CDN location, an unsupported type, or a file that does not
|
|
3664
3666
|
match the message all say so.
|
|
3665
3667
|
|
|
3668
|
+
### When the file is gone from the CDN
|
|
3669
|
+
|
|
3670
|
+
Media is not carried inside the message — the message carries a URL, a hash and
|
|
3671
|
+
the key, and the bytes sit on WhatsApp's CDN for a limited time. A download that
|
|
3672
|
+
answers **404** or **410** has nothing left to fetch. This is most common for
|
|
3673
|
+
messages that arrive through history sync long after they were sent.
|
|
3674
|
+
|
|
3675
|
+
The sender's phone still has the original, and can be asked to upload it again:
|
|
3676
|
+
|
|
3677
|
+
```js
|
|
3678
|
+
client.on('media_retry', (notification) => {
|
|
3679
|
+
const result = client.decryptMediaRetry(notification, mediaKey)
|
|
3680
|
+
if (result.ok) {
|
|
3681
|
+
// fresh location — download it the usual way
|
|
3682
|
+
console.log('re-uploaded at', result.directPath)
|
|
3683
|
+
} else {
|
|
3684
|
+
// the phone no longer has it either; nothing further to try
|
|
3685
|
+
}
|
|
3686
|
+
})
|
|
3687
|
+
|
|
3688
|
+
try {
|
|
3689
|
+
await client.downloadMedia(msg)
|
|
3690
|
+
} catch (err) {
|
|
3691
|
+
if (/404|410/.test(err.message)) {
|
|
3692
|
+
await client.requestMediaRetry({
|
|
3693
|
+
id: msg.key.id,
|
|
3694
|
+
chatJid: msg.key.remoteJid,
|
|
3695
|
+
fromMe: msg.key.fromMe,
|
|
3696
|
+
// groups only — the member who sent it
|
|
3697
|
+
participant: msg.key.participant
|
|
3698
|
+
}, mediaKey)
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
```
|
|
3702
|
+
|
|
3703
|
+
`requestMediaRetry` resolves as soon as the request is on the wire; the answer
|
|
3704
|
+
arrives later on the `media_retry` event, which is why the two halves are
|
|
3705
|
+
written separately. Keep the `mediaKey` — the reply is encrypted under a key
|
|
3706
|
+
derived from it, and without it the fresh location cannot be read.
|
|
3707
|
+
|
|
3708
|
+
That derivation is also what makes the request safe to send: it proves the
|
|
3709
|
+
asker was a recipient of the message rather than someone who merely knows a
|
|
3710
|
+
message id, so no phone can be made to re-upload a file on request.
|
|
3711
|
+
|
|
3666
3712
|
## Sending Messages
|
|
3667
3713
|
|
|
3668
3714
|
### Text Message
|
|
3669
3715
|
|
|
3670
3716
|
```js
|
|
3671
|
-
await client.sendText('919634847671
|
|
3717
|
+
await client.sendText('919634847671', 'Hello!')
|
|
3672
3718
|
```
|
|
3673
3719
|
|
|
3674
3720
|
### Quote Message
|
|
@@ -3704,10 +3750,10 @@ await client.sendText(
|
|
|
3704
3750
|
|
|
3705
3751
|
```js
|
|
3706
3752
|
// react to a message
|
|
3707
|
-
await client.sendReaction('919634847671
|
|
3753
|
+
await client.sendReaction('919634847671', 'MSGID123', '👍')
|
|
3708
3754
|
|
|
3709
3755
|
// remove a reaction — pass empty string
|
|
3710
|
-
await client.sendReaction('919634847671
|
|
3756
|
+
await client.sendReaction('919634847671', 'MSGID123', '')
|
|
3711
3757
|
```
|
|
3712
3758
|
|
|
3713
3759
|
### Edit Message
|
|
@@ -3727,10 +3773,10 @@ await client.editMessage(
|
|
|
3727
3773
|
|
|
3728
3774
|
```js
|
|
3729
3775
|
// delete for yourself only
|
|
3730
|
-
await client.deleteMessage('MSGID123', '919634847671
|
|
3776
|
+
await client.deleteMessage('MSGID123', '919634847671', true, false)
|
|
3731
3777
|
|
|
3732
3778
|
// delete for everyone (revoke)
|
|
3733
|
-
await client.deleteMessage('MSGID123', '919634847671
|
|
3779
|
+
await client.deleteMessage('MSGID123', '919634847671', true, true)
|
|
3734
3780
|
```
|
|
3735
3781
|
|
|
3736
3782
|
### Forward Message
|
|
@@ -3740,12 +3786,12 @@ re-uploading. Pass a decoded message object from the `message` event to forward
|
|
|
3740
3786
|
|
|
3741
3787
|
```js
|
|
3742
3788
|
// Forward text
|
|
3743
|
-
await client.forwardMessage('919634847671
|
|
3789
|
+
await client.forwardMessage('919634847671', 'text to forward')
|
|
3744
3790
|
|
|
3745
3791
|
// Forward any received message (full media, no re-upload)
|
|
3746
3792
|
client.on('message', async (msg) => {
|
|
3747
3793
|
if (msg.decoded && msg.decoded.type !== 'text') {
|
|
3748
|
-
await client.forwardMessage('919634847671
|
|
3794
|
+
await client.forwardMessage('919634847671', msg)
|
|
3749
3795
|
}
|
|
3750
3796
|
})
|
|
3751
3797
|
```
|
|
@@ -3795,10 +3841,10 @@ Send a GPS location pin. `name` and `address` are optional labels shown below th
|
|
|
3795
3841
|
|
|
3796
3842
|
```js
|
|
3797
3843
|
// minimal — lat/lon only
|
|
3798
|
-
await client.sendLocation('919634847671
|
|
3844
|
+
await client.sendLocation('919634847671', 48.8566, 2.3522)
|
|
3799
3845
|
|
|
3800
3846
|
// with name and address
|
|
3801
|
-
await client.sendLocation('919634847671
|
|
3847
|
+
await client.sendLocation('919634847671', 48.8566, 2.3522, {
|
|
3802
3848
|
name: 'Eiffel Tower',
|
|
3803
3849
|
address: 'Champ de Mars, 5 Av. Anatole France, Paris'
|
|
3804
3850
|
})
|
|
@@ -3809,7 +3855,7 @@ await client.sendLocation('120363000000000000@g.us', 51.5074, -0.1278, {
|
|
|
3809
3855
|
})
|
|
3810
3856
|
|
|
3811
3857
|
// with a map image for the bubble — WhatsApp draws a blank card without one
|
|
3812
|
-
await client.sendLocation('919634847671
|
|
3858
|
+
await client.sendLocation('919634847671', 48.8566, 2.3522, {
|
|
3813
3859
|
name: 'Eiffel Tower',
|
|
3814
3860
|
thumbnail: jpegBuffer
|
|
3815
3861
|
})
|
|
@@ -3829,7 +3875,7 @@ const vcard = [
|
|
|
3829
3875
|
'END:VCARD'
|
|
3830
3876
|
].join('\n')
|
|
3831
3877
|
|
|
3832
|
-
await client.sendContact('919634847671
|
|
3878
|
+
await client.sendContact('919634847671', 'Alice Smith', vcard)
|
|
3833
3879
|
```
|
|
3834
3880
|
|
|
3835
3881
|
### Call Link
|
|
@@ -3848,7 +3894,7 @@ const audio = await client.createCallLink()
|
|
|
3848
3894
|
await client.createCallLink('video', { startTime: 1800000000 })
|
|
3849
3895
|
|
|
3850
3896
|
// create it and send it in one step
|
|
3851
|
-
await client.sendCallLink('919634847671
|
|
3897
|
+
await client.sendCallLink('919634847671', 'video', {
|
|
3852
3898
|
text: 'Join me here:'
|
|
3853
3899
|
})
|
|
3854
3900
|
```
|
|
@@ -3869,10 +3915,10 @@ the preview.
|
|
|
3869
3915
|
|
|
3870
3916
|
```js
|
|
3871
3917
|
// from file path
|
|
3872
|
-
await client.sendImage('919634847671
|
|
3918
|
+
await client.sendImage('919634847671', './photo.jpg', { caption: 'Look at this' })
|
|
3873
3919
|
|
|
3874
3920
|
// from Buffer
|
|
3875
|
-
await client.sendImage('919634847671
|
|
3921
|
+
await client.sendImage('919634847671', buffer, {
|
|
3876
3922
|
caption: 'Photo',
|
|
3877
3923
|
mimetype: 'image/jpeg'
|
|
3878
3924
|
})
|
|
@@ -3881,26 +3927,26 @@ await client.sendImage('919634847671@s.whatsapp.net', buffer, {
|
|
|
3881
3927
|
### Video Message
|
|
3882
3928
|
|
|
3883
3929
|
```js
|
|
3884
|
-
await client.sendVideo('919634847671
|
|
3930
|
+
await client.sendVideo('919634847671', './clip.mp4', { caption: 'Watch this' })
|
|
3885
3931
|
```
|
|
3886
3932
|
|
|
3887
3933
|
### Audio Message
|
|
3888
3934
|
|
|
3889
3935
|
```js
|
|
3890
|
-
await client.sendAudio('919634847671
|
|
3936
|
+
await client.sendAudio('919634847671', './song.mp3')
|
|
3891
3937
|
```
|
|
3892
3938
|
|
|
3893
3939
|
### Voice Note
|
|
3894
3940
|
|
|
3895
3941
|
```js
|
|
3896
3942
|
// ptt: true renders the audio as a push-to-talk voice note with waveform
|
|
3897
|
-
await client.sendAudio('919634847671
|
|
3943
|
+
await client.sendAudio('919634847671', './voice.ogg', { ptt: true })
|
|
3898
3944
|
```
|
|
3899
3945
|
|
|
3900
3946
|
### Document Message
|
|
3901
3947
|
|
|
3902
3948
|
```js
|
|
3903
|
-
await client.sendDocument('919634847671
|
|
3949
|
+
await client.sendDocument('919634847671', './report.pdf', {
|
|
3904
3950
|
fileName: 'Q1 Report.pdf'
|
|
3905
3951
|
})
|
|
3906
3952
|
```
|
|
@@ -3908,7 +3954,7 @@ await client.sendDocument('919634847671@s.whatsapp.net', './report.pdf', {
|
|
|
3908
3954
|
### Sticker Message
|
|
3909
3955
|
|
|
3910
3956
|
```js
|
|
3911
|
-
await client.sendSticker('919634847671
|
|
3957
|
+
await client.sendSticker('919634847671', './sticker.webp')
|
|
3912
3958
|
```
|
|
3913
3959
|
|
|
3914
3960
|
## Status / Stories
|
|
@@ -3931,7 +3977,7 @@ await client.sendStatus({ audio: './voice.ogg' })
|
|
|
3931
3977
|
await client.sendStatus('Colorful', { backgroundArgb: 0xFF25D366, font: 3 })
|
|
3932
3978
|
|
|
3933
3979
|
// post to an explicit list instead of the privacy-derived one
|
|
3934
|
-
await client.sendStatus('Hi', { recipients: ['919634847671
|
|
3980
|
+
await client.sendStatus('Hi', { recipients: ['919634847671'] })
|
|
3935
3981
|
```
|
|
3936
3982
|
|
|
3937
3983
|
### Status Privacy
|
|
@@ -3956,7 +4002,7 @@ pass `recipients` in that case.
|
|
|
3956
4002
|
|
|
3957
4003
|
```js
|
|
3958
4004
|
// mark all messages in a chat as read (sends IQ to server)
|
|
3959
|
-
await client.markChatRead('919634847671
|
|
4005
|
+
await client.markChatRead('919634847671')
|
|
3960
4006
|
```
|
|
3961
4007
|
|
|
3962
4008
|
### Mark Voice Message Played
|
|
@@ -3965,7 +4011,7 @@ Send a `played` receipt for a received voice note (push-to-talk audio). This tel
|
|
|
3965
4011
|
|
|
3966
4012
|
```js
|
|
3967
4013
|
// msgId: ID of the audio message, from: JID of the sender
|
|
3968
|
-
client.markMessagePlayed('3EB0ABCDEF123456', '919634847671
|
|
4014
|
+
client.markMessagePlayed('3EB0ABCDEF123456', '919634847671')
|
|
3969
4015
|
```
|
|
3970
4016
|
|
|
3971
4017
|
### Update Presence
|
|
@@ -3976,9 +4022,9 @@ client.setOnline(true)
|
|
|
3976
4022
|
client.setOnline(false)
|
|
3977
4023
|
|
|
3978
4024
|
// show typing or recording in a specific chat
|
|
3979
|
-
client.setChatPresence('919634847671
|
|
3980
|
-
client.setChatPresence('919634847671
|
|
3981
|
-
client.setChatPresence('919634847671
|
|
4025
|
+
client.setChatPresence('919634847671', 'composing') // typing
|
|
4026
|
+
client.setChatPresence('919634847671', 'recording') // recording audio
|
|
4027
|
+
client.setChatPresence('919634847671', 'paused') // stopped
|
|
3982
4028
|
```
|
|
3983
4029
|
|
|
3984
4030
|
## Modifying Chats
|
|
@@ -3996,7 +4042,7 @@ They return a boolean: **whether the change reached app state**, and so whether
|
|
|
3996
4042
|
other devices will see it.
|
|
3997
4043
|
|
|
3998
4044
|
```js
|
|
3999
|
-
const synced = await client.pinChat('919634847671
|
|
4045
|
+
const synced = await client.pinChat('919634847671')
|
|
4000
4046
|
if (!synced) console.log('pinned here, but your phone will not know')
|
|
4001
4047
|
```
|
|
4002
4048
|
|
|
@@ -4023,16 +4069,16 @@ if (!synced) console.log('pinned here, but your phone will not know')
|
|
|
4023
4069
|
### Archive / Unarchive a Chat
|
|
4024
4070
|
|
|
4025
4071
|
```js
|
|
4026
|
-
await client.archiveChat('919634847671
|
|
4027
|
-
await client.unarchiveChat('919634847671
|
|
4072
|
+
await client.archiveChat('919634847671')
|
|
4073
|
+
await client.unarchiveChat('919634847671')
|
|
4028
4074
|
```
|
|
4029
4075
|
|
|
4030
4076
|
### Mute / Unmute a Chat
|
|
4031
4077
|
|
|
4032
4078
|
```js
|
|
4033
|
-
await client.muteChat('919634847671
|
|
4034
|
-
await client.muteChat('919634847671
|
|
4035
|
-
await client.unmuteChat('919634847671
|
|
4079
|
+
await client.muteChat('919634847671', 8 * 60 * 60 * 1000) // 8 hours
|
|
4080
|
+
await client.muteChat('919634847671', 0) // until unmuted
|
|
4081
|
+
await client.unmuteChat('919634847671')
|
|
4036
4082
|
```
|
|
4037
4083
|
|
|
4038
4084
|
### Mark a Chat Read / Unread
|
|
@@ -4041,15 +4087,15 @@ This is the chat's own unread badge. To send read receipts (blue ticks) for
|
|
|
4041
4087
|
particular messages, use `markRead()` instead.
|
|
4042
4088
|
|
|
4043
4089
|
```js
|
|
4044
|
-
await client.markChatRead('919634847671
|
|
4045
|
-
await client.markChatUnread('919634847671
|
|
4090
|
+
await client.markChatRead('919634847671')
|
|
4091
|
+
await client.markChatUnread('919634847671')
|
|
4046
4092
|
```
|
|
4047
4093
|
|
|
4048
4094
|
### Pin / Unpin a Chat
|
|
4049
4095
|
|
|
4050
4096
|
```js
|
|
4051
|
-
await client.pinChat('919634847671
|
|
4052
|
-
await client.unpinChat('919634847671
|
|
4097
|
+
await client.pinChat('919634847671')
|
|
4098
|
+
await client.unpinChat('919634847671')
|
|
4053
4099
|
```
|
|
4054
4100
|
|
|
4055
4101
|
### Star / Unstar a Message
|
|
@@ -4058,8 +4104,8 @@ The third argument says whether the message being starred is one you sent. It is
|
|
|
4058
4104
|
part of how the star is filed, so getting it wrong stars a different message.
|
|
4059
4105
|
|
|
4060
4106
|
```js
|
|
4061
|
-
await client.starMessage('MSGID123', '919634847671
|
|
4062
|
-
await client.unstarMessage('MSGID123', '919634847671
|
|
4107
|
+
await client.starMessage('MSGID123', '919634847671', true) // yours
|
|
4108
|
+
await client.unstarMessage('MSGID123', '919634847671', false) // theirs
|
|
4063
4109
|
```
|
|
4064
4110
|
|
|
4065
4111
|
<a id="app-state-sync"></a>
|
|
@@ -4146,11 +4192,11 @@ client.on('app_state_mutation', ({ collection, index, action, removed }) => {
|
|
|
4146
4192
|
|
|
4147
4193
|
```js
|
|
4148
4194
|
// set disappearing timer for a specific chat (DM or group)
|
|
4149
|
-
await client.changeEphemeralTimer('919634847671
|
|
4195
|
+
await client.changeEphemeralTimer('919634847671', 86400)
|
|
4150
4196
|
await client.changeEphemeralTimer('120363000000000000@g.us', 604800)
|
|
4151
4197
|
|
|
4152
4198
|
// remove disappearing messages
|
|
4153
|
-
await client.changeEphemeralTimer('919634847671
|
|
4199
|
+
await client.changeEphemeralTimer('919634847671', 0)
|
|
4154
4200
|
```
|
|
4155
4201
|
|
|
4156
4202
|
## User Queries
|
|
@@ -4175,7 +4221,7 @@ const results = await client.hasWhatsapp(['919634847671', '12345678901'])
|
|
|
4175
4221
|
### Fetch Profile About
|
|
4176
4222
|
|
|
4177
4223
|
```js
|
|
4178
|
-
const about = await client.queryAbout('919634847671
|
|
4224
|
+
const about = await client.queryAbout('919634847671')
|
|
4179
4225
|
console.log(about)
|
|
4180
4226
|
|
|
4181
4227
|
// your own, asked the same way
|
|
@@ -4185,7 +4231,7 @@ console.log(await client.queryOwnAbout())
|
|
|
4185
4231
|
### Fetch Profile Picture
|
|
4186
4232
|
|
|
4187
4233
|
```js
|
|
4188
|
-
const url = await client.queryPicture('919634847671
|
|
4234
|
+
const url = await client.queryPicture('919634847671')
|
|
4189
4235
|
// also works for groups
|
|
4190
4236
|
const groupUrl = await client.queryPicture('120363000000000000@g.us')
|
|
4191
4237
|
```
|
|
@@ -4194,7 +4240,7 @@ const groupUrl = await client.queryPicture('120363000000000000@g.us')
|
|
|
4194
4240
|
|
|
4195
4241
|
```js
|
|
4196
4242
|
// triggers 'presence' events when the contact comes online or goes offline
|
|
4197
|
-
client.subscribeToPresence('919634847671
|
|
4243
|
+
client.subscribeToPresence('919634847671')
|
|
4198
4244
|
|
|
4199
4245
|
client.on('presence', ({ from, available }) => {
|
|
4200
4246
|
console.log(from, available ? 'online' : 'offline')
|
|
@@ -4303,8 +4349,8 @@ await client.changeProfilePicture(buf, { size: 640, quality: 50 })
|
|
|
4303
4349
|
|
|
4304
4350
|
```js
|
|
4305
4351
|
// both return the updated block list, and throw if the server refuses
|
|
4306
|
-
const blocked = await client.blockContact('919634847671
|
|
4307
|
-
await client.unblockContact('919634847671
|
|
4352
|
+
const blocked = await client.blockContact('919634847671')
|
|
4353
|
+
await client.unblockContact('919634847671')
|
|
4308
4354
|
|
|
4309
4355
|
// the block list is addressed by LID; a phone number is resolved to one first,
|
|
4310
4356
|
// looking it up if it is not already known
|
|
@@ -4426,9 +4472,9 @@ for (const r of results) {
|
|
|
4426
4472
|
else console.log('failed', r.jid, r.status, r.needsInvite ? '(invite instead)' : '')
|
|
4427
4473
|
}
|
|
4428
4474
|
|
|
4429
|
-
await client.removeGroupParticipants(groupJid, ['919634847671
|
|
4430
|
-
await client.promoteGroupParticipants(groupJid, ['919634847671
|
|
4431
|
-
await client.demoteGroupParticipants(groupJid, ['919634847671
|
|
4475
|
+
await client.removeGroupParticipants(groupJid, ['919634847671'])
|
|
4476
|
+
await client.promoteGroupParticipants(groupJid, ['919634847671'])
|
|
4477
|
+
await client.demoteGroupParticipants(groupJid, ['919634847671'])
|
|
4432
4478
|
```
|
|
4433
4479
|
|
|
4434
4480
|
Each result looks like this:
|
|
@@ -4709,7 +4755,7 @@ groupName, jpegThumbnail, caption, isCommunity }`.
|
|
|
4709
4755
|
To withdraw an invitation you sent before it is used:
|
|
4710
4756
|
|
|
4711
4757
|
```js
|
|
4712
|
-
await client.revokeGroupInviteForParticipant(groupJid, '919634847671
|
|
4758
|
+
await client.revokeGroupInviteForParticipant(groupJid, '919634847671')
|
|
4713
4759
|
```
|
|
4714
4760
|
|
|
4715
4761
|
An expired or already-spent invitation throws rather than resolving to nothing,
|
|
@@ -4801,7 +4847,7 @@ await client.sendNewsletterText('120363000000000004@newsletter', 'Breaking: What
|
|
|
4801
4847
|
Query the public business profile of any WhatsApp Business account:
|
|
4802
4848
|
|
|
4803
4849
|
```js
|
|
4804
|
-
const bp = await client.queryBusinessProfile('919634847671
|
|
4850
|
+
const bp = await client.queryBusinessProfile('919634847671')
|
|
4805
4851
|
if (bp) {
|
|
4806
4852
|
console.log(bp.category) // e.g. "Software & IT Services"
|
|
4807
4853
|
console.log(bp.email) // business email (if set)
|
package/lib/Client.js
CHANGED
|
@@ -571,7 +571,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
571
571
|
const device = this._store.device || getDeviceConfig();
|
|
572
572
|
const business = !!device.business;
|
|
573
573
|
const android = device.os === 'android';
|
|
574
|
-
|
|
574
|
+
// Named for where the answer comes from, since that is the first thing
|
|
575
|
+
// anyone reading a version line wants to know. Android is answered by
|
|
576
|
+
// Play's API now, not by the store page it used to scrape.
|
|
577
|
+
const source = android ? 'Play Store' : 'App Store listing';
|
|
575
578
|
|
|
576
579
|
try {
|
|
577
580
|
const { compareVersions, fetchAndroidVersionLive } = require('./Registration');
|
|
@@ -1420,6 +1423,71 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1420
1423
|
this.emit('close');
|
|
1421
1424
|
}
|
|
1422
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* Ask the sender's phone to upload a media file again.
|
|
1428
|
+
*
|
|
1429
|
+
* Media lives on the CDN for a while, not forever, and the message only ever
|
|
1430
|
+
* carried the URL and the key. A download that comes back 404 or 410 — most
|
|
1431
|
+
* often for something that arrived through history sync long after it was
|
|
1432
|
+
* sent — has nothing left to fetch, and until now that was where it ended:
|
|
1433
|
+
* the message looked fine and the file was simply gone.
|
|
1434
|
+
*
|
|
1435
|
+
* The sender still has the original. This asks for it back.
|
|
1436
|
+
*
|
|
1437
|
+
* The request is encrypted under a key derived from the message's own media
|
|
1438
|
+
* key, so it is proof that we were a recipient rather than merely someone who
|
|
1439
|
+
* knows a message id — no phone can be made to re-upload on request.
|
|
1440
|
+
*
|
|
1441
|
+
* Resolves once the request is on the wire. The answer arrives later as a
|
|
1442
|
+
* `media_retry` event; pass it and the same media key to
|
|
1443
|
+
* decryptMediaRetry() for the fresh path.
|
|
1444
|
+
*
|
|
1445
|
+
* client.on('media_retry', (n) => {
|
|
1446
|
+
* const r = client.decryptMediaRetry(n, mediaKey)
|
|
1447
|
+
* if (r.ok) // re-download from r.directPath
|
|
1448
|
+
* })
|
|
1449
|
+
*
|
|
1450
|
+
* @param {object} info { id, chatJid, fromMe, participant }
|
|
1451
|
+
* `participant` only for a group — the member who sent it.
|
|
1452
|
+
* @param {Buffer} mediaKey the media key off the original message
|
|
1453
|
+
* @returns {Promise<void>}
|
|
1454
|
+
*/
|
|
1455
|
+
async requestMediaRetry(info, mediaKey) {
|
|
1456
|
+
if (!this._socket || !this._connected) {
|
|
1457
|
+
throw new Error('Not connected — cannot request a media retry');
|
|
1458
|
+
}
|
|
1459
|
+
if (!info || !info.id) throw new Error('requestMediaRetry: info.id is required');
|
|
1460
|
+
if (!info.chatJid) throw new Error('requestMediaRetry: info.chatJid is required');
|
|
1461
|
+
if (!mediaKey || !mediaKey.length) {
|
|
1462
|
+
throw new Error('requestMediaRetry: the original message\'s mediaKey is required');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const { buildRetryReceiptNode } = require('./messages/MediaRetry');
|
|
1466
|
+
// Addressed to ourselves: the receipt is a report about a message we hold,
|
|
1467
|
+
// and the server routes it on to the sender.
|
|
1468
|
+
const node = buildRetryReceiptNode(info, mediaKey, this._ownDeviceJid(), BinaryNode);
|
|
1469
|
+
this._socket.sendNode(node);
|
|
1470
|
+
_whaDbg('[DBG] MEDIA_RETRY requested id=' + info.id + ' chat=' + info.chatJid +
|
|
1471
|
+
(info.participant ? ' participant=' + info.participant : ''));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Read the answer to requestMediaRetry().
|
|
1476
|
+
*
|
|
1477
|
+
* @param {object} notification a `media_retry` event payload
|
|
1478
|
+
* @param {Buffer} mediaKey the same key the request was made with
|
|
1479
|
+
* @returns {{ok: boolean, result: number|null, directPath: string|null}}
|
|
1480
|
+
* `ok` false means the phone answered but could not oblige — it no
|
|
1481
|
+
* longer has the file either, and there is nothing further to try.
|
|
1482
|
+
*/
|
|
1483
|
+
decryptMediaRetry(notification, mediaKey) {
|
|
1484
|
+
const { decryptRetryNotification } = require('./messages/MediaRetry');
|
|
1485
|
+
if (notification && notification.error) {
|
|
1486
|
+
return { ok: false, result: null, directPath: null, error: notification.error };
|
|
1487
|
+
}
|
|
1488
|
+
return decryptRetryNotification(notification, mediaKey);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1423
1491
|
/**
|
|
1424
1492
|
* Ask the server whether this session's registration is still alive.
|
|
1425
1493
|
*
|
|
@@ -3529,6 +3597,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3529
3597
|
this._checkAndReplenishPreKeys();
|
|
3530
3598
|
}
|
|
3531
3599
|
|
|
3600
|
+
// The sender's phone answering a re-upload request — see
|
|
3601
|
+
// requestMediaRetry(). Surfaced raw as well as decrypted, since only the
|
|
3602
|
+
// caller holds the media key the payload is encrypted under.
|
|
3603
|
+
if (type === 'mediaretry') {
|
|
3604
|
+
try {
|
|
3605
|
+
const { parseRetryNotification } = require('./messages/MediaRetry');
|
|
3606
|
+
const parsed = parseRetryNotification(node, {
|
|
3607
|
+
findChild, getContent: getNodeContent
|
|
3608
|
+
});
|
|
3609
|
+
if (parsed) {
|
|
3610
|
+
_whaDbg('[DBG] MEDIA_RETRY notification id=' + parsed.messageId +
|
|
3611
|
+
(parsed.error ? ' error=' + parsed.error.code : ' payload=' +
|
|
3612
|
+
(parsed.ciphertext ? parsed.ciphertext.length + 'B' : 'none')));
|
|
3613
|
+
this.emit('media_retry', parsed);
|
|
3614
|
+
}
|
|
3615
|
+
} catch (err) {
|
|
3616
|
+
_whaDbg('[DBG] MEDIA_RETRY parse failed: ' + (err && err.message));
|
|
3617
|
+
}
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3532
3621
|
if (type === 'account_sync') {
|
|
3533
3622
|
// Account sync notification — may contain device list updates
|
|
3534
3623
|
const devicesNode = findChildDeep(node, 'devices');
|
package/lib/Registration.js
CHANGED
|
@@ -655,8 +655,29 @@ async function fetchPlayVersion(business) {
|
|
|
655
655
|
async function fetchAndroidVersionLive(business) {
|
|
656
656
|
const fromPlay = await fetchPlayVersion(business);
|
|
657
657
|
if (fromPlay) return fromPlay;
|
|
658
|
-
|
|
659
|
-
|
|
658
|
+
|
|
659
|
+
// Play did not answer. That happens on any host it will not serve — a
|
|
660
|
+
// datacenter IP is the usual one, since the dispenser handing out the
|
|
661
|
+
// anonymous account rate-limits them hard — so this path is what a bot on
|
|
662
|
+
// hosting actually runs, every time.
|
|
663
|
+
//
|
|
664
|
+
// The store page is tried next, but it cannot be trusted on its own: it has
|
|
665
|
+
// been observed answering 2.26.27.85 while Play was serving 2.26.31.77. Left
|
|
666
|
+
// as-is that is worse than useless, because a value older than the pinned
|
|
667
|
+
// version still counts as an answer and shadows it — the caller's "never go
|
|
668
|
+
// backwards" guard then reads it as "already current" and the session sits
|
|
669
|
+
// on whatever it had, with the newer pinned version never once applied.
|
|
670
|
+
//
|
|
671
|
+
// So the pinned version is a floor, not a last resort: anything the page
|
|
672
|
+
// says that is older than it is discarded in its favour.
|
|
673
|
+
const fromPage = await fetchAndroidVersion(business);
|
|
674
|
+
if (fromPage && compareVersions(fromPage, ANDROID_VERSION_FALLBACK) > 0) {
|
|
675
|
+
_whaDbg('[DBG] PLAY_VERSION unavailable — the store page says ' + fromPage);
|
|
676
|
+
return fromPage;
|
|
677
|
+
}
|
|
678
|
+
_whaDbg('[DBG] PLAY_VERSION unavailable, and the store page said ' +
|
|
679
|
+
(fromPage || 'nothing') + ' — using the pinned ' + ANDROID_VERSION_FALLBACK);
|
|
680
|
+
return ANDROID_VERSION_FALLBACK;
|
|
660
681
|
}
|
|
661
682
|
|
|
662
683
|
async function fetchIosVersion(business) {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Asking the sender's phone to upload a media file again.
|
|
4
|
+
//
|
|
5
|
+
// Media is not carried in the message. The message carries a URL, a SHA-256 of
|
|
6
|
+
// the encrypted bytes and the key to decrypt them, and the bytes themselves sit
|
|
7
|
+
// on WhatsApp's CDN with a limited life. A file that has aged out, or one whose
|
|
8
|
+
// message arrived through history sync long after the fact, answers the
|
|
9
|
+
// download with 404 or 410 — and at that point the media is gone as far as this
|
|
10
|
+
// client is concerned, however healthy the message looks.
|
|
11
|
+
//
|
|
12
|
+
// The sender's phone still has the original. This is how it is asked to put it
|
|
13
|
+
// back: a <receipt type="server-error"> naming the message, carrying a small
|
|
14
|
+
// encrypted payload that proves we are the intended recipient. The phone
|
|
15
|
+
// re-uploads, and the answer comes back as a <notification type="mediaretry">
|
|
16
|
+
// with a fresh URL — encrypted the same way, so only we can read it.
|
|
17
|
+
//
|
|
18
|
+
// The proof is what makes this safe. The receipt encrypts the message id under
|
|
19
|
+
// a key derived from the message's own media key, which only the sender and the
|
|
20
|
+
// recipients hold. Someone who merely knows a message id cannot produce a valid
|
|
21
|
+
// one, so this cannot be used to make a phone re-upload anything on request.
|
|
22
|
+
//
|
|
23
|
+
// receipt key = HKDF-SHA256(mediaKey, info = "WhatsApp Media Retry Notification", 32)
|
|
24
|
+
// ciphertext = AES-256-GCM(receipt key, iv = random 12B, ServerErrorReceipt,
|
|
25
|
+
// aad = message id)
|
|
26
|
+
//
|
|
27
|
+
// The notification comes back under the same key, with the same message id as
|
|
28
|
+
// the additional data.
|
|
29
|
+
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
const { hkdf } = require('@noble/hashes/hkdf');
|
|
32
|
+
const { sha256 } = require('@noble/hashes/sha256');
|
|
33
|
+
|
|
34
|
+
const RETRY_KEY_INFO = 'WhatsApp Media Retry Notification';
|
|
35
|
+
const IV_SIZE = 12;
|
|
36
|
+
|
|
37
|
+
// ─── protobuf helpers ───────────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// Two tiny messages, hand-encoded rather than pulled through the schema — the
|
|
40
|
+
// whole of what travels here is one string out and a handful of fields back.
|
|
41
|
+
|
|
42
|
+
function pbString(field, str) {
|
|
43
|
+
const buf = Buffer.from(str, 'utf8');
|
|
44
|
+
return Buffer.concat([Buffer.from([(field << 3) | 2]), varint(buf.length), buf]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function varint(n) {
|
|
48
|
+
const out = [];
|
|
49
|
+
do { let b = n & 0x7f; n >>>= 7; if (n) b |= 0x80; out.push(b); } while (n);
|
|
50
|
+
return Buffer.from(out);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readVarint(buf, pos) {
|
|
54
|
+
let result = 0, shift = 0, byte;
|
|
55
|
+
do {
|
|
56
|
+
if (pos >= buf.length) throw new Error('truncated varint');
|
|
57
|
+
byte = buf[pos++];
|
|
58
|
+
result |= (byte & 0x7f) << shift;
|
|
59
|
+
shift += 7;
|
|
60
|
+
} while (byte & 0x80);
|
|
61
|
+
return [result, pos];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every field of the notification, by number. Unknown ones are kept rather than
|
|
65
|
+
// dropped so a schema that grows does not read as a corrupt message here.
|
|
66
|
+
function readFields(buf) {
|
|
67
|
+
const out = {};
|
|
68
|
+
let pos = 0;
|
|
69
|
+
while (pos < buf.length) {
|
|
70
|
+
let key; [key, pos] = readVarint(buf, pos);
|
|
71
|
+
const num = key >> 3, wire = key & 7;
|
|
72
|
+
if (wire === 2) {
|
|
73
|
+
let len; [len, pos] = readVarint(buf, pos);
|
|
74
|
+
out[num] = buf.slice(pos, pos + len);
|
|
75
|
+
pos += len;
|
|
76
|
+
} else if (wire === 0) {
|
|
77
|
+
let val; [val, pos] = readVarint(buf, pos);
|
|
78
|
+
out[num] = val;
|
|
79
|
+
} else if (wire === 5) { pos += 4; }
|
|
80
|
+
else if (wire === 1) { pos += 8; }
|
|
81
|
+
else break;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── key derivation ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** The AES key both halves of the exchange are encrypted under. */
|
|
89
|
+
function mediaRetryKey(mediaKey) {
|
|
90
|
+
const ikm = Buffer.isBuffer(mediaKey) ? mediaKey : Buffer.from(mediaKey);
|
|
91
|
+
return Buffer.from(
|
|
92
|
+
hkdf(sha256, ikm, Buffer.alloc(0), Buffer.from(RETRY_KEY_INFO, 'utf8'), 32));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Encrypt the receipt that asks for a re-upload.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} msgId the message whose media is missing
|
|
99
|
+
* @param {Buffer} mediaKey that message's media key
|
|
100
|
+
* @returns {{ciphertext: Buffer, iv: Buffer}}
|
|
101
|
+
*/
|
|
102
|
+
function encryptRetryReceipt(msgId, mediaKey) {
|
|
103
|
+
// ServerErrorReceipt { stanzaId = 1 }
|
|
104
|
+
const plaintext = pbString(1, msgId);
|
|
105
|
+
const iv = crypto.randomBytes(IV_SIZE);
|
|
106
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', mediaRetryKey(mediaKey), iv);
|
|
107
|
+
// The message id is the additional data, so a receipt cannot be lifted from
|
|
108
|
+
// one message and replayed against another.
|
|
109
|
+
cipher.setAAD(Buffer.from(msgId, 'utf8'));
|
|
110
|
+
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
111
|
+
return { ciphertext: Buffer.concat([enc, cipher.getAuthTag()]), iv };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decrypt the notification that answers one.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} notif { messageId, ciphertext, iv } off the wire
|
|
118
|
+
* @param {Buffer} mediaKey the same media key the receipt was sent with
|
|
119
|
+
* @returns {{result: number, directPath: string|null, url: string|null,
|
|
120
|
+
* handle: string|null, ciphertextSha256: Buffer|null}}
|
|
121
|
+
*/
|
|
122
|
+
function decryptRetryNotification(notif, mediaKey) {
|
|
123
|
+
const { messageId, ciphertext, iv } = notif;
|
|
124
|
+
if (!ciphertext || !iv) throw new Error('media retry notification carried no payload');
|
|
125
|
+
|
|
126
|
+
const key = mediaRetryKey(mediaKey);
|
|
127
|
+
const tag = ciphertext.slice(ciphertext.length - 16);
|
|
128
|
+
const body = ciphertext.slice(0, ciphertext.length - 16);
|
|
129
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
130
|
+
decipher.setAuthTag(tag);
|
|
131
|
+
decipher.setAAD(Buffer.from(messageId, 'utf8'));
|
|
132
|
+
const plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
133
|
+
|
|
134
|
+
// MediaRetryNotification { stanzaId = 1, directPath = 2, result = 3 }
|
|
135
|
+
const fields = readFields(plaintext);
|
|
136
|
+
const result = typeof fields[3] === 'number' ? fields[3] : null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
result,
|
|
140
|
+
// 1 = SUCCESS in the enum; anything else means the phone could not oblige.
|
|
141
|
+
ok: result === 1,
|
|
142
|
+
directPath: fields[2] ? fields[2].toString('utf8') : null,
|
|
143
|
+
stanzaId: fields[1] ? fields[1].toString('utf8') : messageId
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The <receipt> that asks the sender's phone for a re-upload.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} info { id, chatJid, fromMe, participant }
|
|
151
|
+
* @param {Buffer} mediaKey
|
|
152
|
+
* @param {string} ownJid who the receipt is addressed to — ourselves
|
|
153
|
+
* @param {Function} BinaryNode
|
|
154
|
+
*/
|
|
155
|
+
function buildRetryReceiptNode(info, mediaKey, ownJid, BinaryNode) {
|
|
156
|
+
const { ciphertext, iv } = encryptRetryReceipt(info.id, mediaKey);
|
|
157
|
+
|
|
158
|
+
const rmrAttrs = {
|
|
159
|
+
jid: info.chatJid,
|
|
160
|
+
from_me: String(!!info.fromMe)
|
|
161
|
+
};
|
|
162
|
+
// Only a group receipt names the member the message came from; on a DM the
|
|
163
|
+
// chat jid already says it, and sending it anyway is a shape no real client
|
|
164
|
+
// produces.
|
|
165
|
+
if (info.participant) rmrAttrs.participant = info.participant;
|
|
166
|
+
|
|
167
|
+
return new BinaryNode('receipt', {
|
|
168
|
+
id: info.id,
|
|
169
|
+
to: ownJid,
|
|
170
|
+
type: 'server-error'
|
|
171
|
+
}, [
|
|
172
|
+
new BinaryNode('encrypt', {}, [
|
|
173
|
+
new BinaryNode('enc_p', {}, ciphertext),
|
|
174
|
+
new BinaryNode('enc_iv', {}, iv)
|
|
175
|
+
]),
|
|
176
|
+
new BinaryNode('rmr', rmrAttrs, null)
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read a <notification type="mediaretry"> into the pieces the caller needs.
|
|
182
|
+
*
|
|
183
|
+
* Returns null when the node is not one, so a caller can hand it every
|
|
184
|
+
* notification without checking first. An `error` in the result means the phone
|
|
185
|
+
* answered but declined — usually because it no longer has the file either.
|
|
186
|
+
*/
|
|
187
|
+
function parseRetryNotification(node, helpers) {
|
|
188
|
+
const { findChild, getContent } = helpers;
|
|
189
|
+
const attrs = (node && node.attrs) || {};
|
|
190
|
+
|
|
191
|
+
const rmr = findChild(node, 'rmr');
|
|
192
|
+
if (!rmr) return null;
|
|
193
|
+
|
|
194
|
+
const out = {
|
|
195
|
+
messageId: attrs.id || null,
|
|
196
|
+
timestamp: attrs.t ? Number(attrs.t) : null,
|
|
197
|
+
chatJid: (rmr.attrs && rmr.attrs.jid) || null,
|
|
198
|
+
fromMe: !!(rmr.attrs && rmr.attrs.from_me === 'true'),
|
|
199
|
+
participant: (rmr.attrs && rmr.attrs.participant) || null,
|
|
200
|
+
error: null,
|
|
201
|
+
ciphertext: null,
|
|
202
|
+
iv: null
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const errNode = findChild(node, 'error');
|
|
206
|
+
if (errNode) {
|
|
207
|
+
out.error = { code: (errNode.attrs && Number(errNode.attrs.code)) || 0 };
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const encrypt = findChild(node, 'encrypt');
|
|
212
|
+
if (encrypt) {
|
|
213
|
+
const p = findChild(encrypt, 'enc_p');
|
|
214
|
+
const ivN = findChild(encrypt, 'enc_iv');
|
|
215
|
+
out.ciphertext = p ? getContent(p) : null;
|
|
216
|
+
out.iv = ivN ? getContent(ivN) : null;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
mediaRetryKey,
|
|
223
|
+
encryptRetryReceipt,
|
|
224
|
+
decryptRetryNotification,
|
|
225
|
+
buildRetryReceiptNode,
|
|
226
|
+
parseRetryNotification,
|
|
227
|
+
RETRY_KEY_INFO
|
|
228
|
+
};
|