Skip to content

Commit 96b7716

Browse files
Fix active group membership resolution
1 parent db83391 commit 96b7716

5 files changed

Lines changed: 264 additions & 12 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "PyWABackupAPI"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Python port of SwiftWABackupAPI for exploring WhatsApp data in iPhone backups"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/pywabackupapi/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
)
3030
from .utils import canonical_json_dumps
3131

32+
__version__ = "0.1.1"
33+
3234
__all__ = [
3335
"BackupError",
3436
"BackupFetchResult",
@@ -57,5 +59,6 @@
5759
"UnsupportedSchemaError",
5860
"WABackup",
5961
"WABackupDelegate",
62+
"__version__",
6063
"canonical_json_dumps",
6164
]

src/pywabackupapi/api.py

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ class GroupMember:
435435

436436
TABLE_NAME = "ZWAGROUPMEMBER"
437437
EXPECTED_COLUMNS = {"Z_PK", "ZMEMBERJID", "ZCONTACTNAME"}
438+
ACTIVE_MEMBERSHIP_COLUMNS = {"ZCHATSESSION", "ZISACTIVE"}
438439

439440
@classmethod
440441
def from_row(cls, row: sqlite3.Row) -> "GroupMember":
@@ -456,6 +457,25 @@ def fetch_group_member(cls, member_id: int, connection: sqlite3.Connection) -> "
456457
).fetchone()
457458
return None if row is None else cls.from_row(row)
458459

460+
@classmethod
461+
def fetch_active_group_members(cls, chat_id: int, connection: sqlite3.Connection) -> list["GroupMember"]:
462+
rows = connection.execute(f"PRAGMA table_info({cls.TABLE_NAME})").fetchall()
463+
column_names = {str(row["name"]).upper() for row in rows}
464+
if not cls.ACTIVE_MEMBERSHIP_COLUMNS.issubset(column_names):
465+
return []
466+
467+
rows = connection.execute(
468+
f"""
469+
SELECT *
470+
FROM {cls.TABLE_NAME}
471+
WHERE ZCHATSESSION = ?
472+
AND IFNULL(ZISACTIVE, 0) = 1
473+
ORDER BY Z_PK
474+
""",
475+
(chat_id,),
476+
).fetchall()
477+
return [cls.from_row(row) for row in rows]
478+
459479
@classmethod
460480
def fetch_group_member_ids(cls, chat_id: int, connection: sqlite3.Connection) -> list[int]:
461481
supported = SupportedMessageType.all_values()
@@ -1170,12 +1190,35 @@ def fetchGroupMemberInfo(
11701190
group_member = GroupMember.fetch_group_member(memberId, connection)
11711191
if group_member is None:
11721192
return None
1193+
return self.fetchResolvedGroupMemberInfo(group_member, connection)
1194+
1195+
def fetchResolvedGroupMemberInfo(
1196+
self,
1197+
groupMember: GroupMember,
1198+
connection: sqlite3.Connection,
1199+
) -> tuple[str | None, str | None]:
11731200
return self.obtainSenderInfo(
1174-
jid=group_member.memberJid,
1175-
contactNameGroupMember=group_member.contactName,
1201+
jid=groupMember.memberJid,
1202+
contactNameGroupMember=groupMember.contactName,
11761203
connection=connection,
11771204
)
11781205

1206+
def fetchGroupContactMembers(
1207+
self,
1208+
chatId: int,
1209+
connection: sqlite3.Connection,
1210+
) -> list[GroupMember]:
1211+
active_members = GroupMember.fetch_active_group_members(chatId, connection)
1212+
if active_members:
1213+
return active_members
1214+
1215+
members: list[GroupMember] = []
1216+
for member_id in GroupMember.fetch_group_member_ids(chatId, connection):
1217+
group_member = GroupMember.fetch_group_member(member_id, connection)
1218+
if group_member is not None:
1219+
members.append(group_member)
1220+
return members
1221+
11791222
def fetchDuration(self, mediaItemId: int, connection: sqlite3.Connection) -> int | None:
11801223
media_item = MediaItem.fetch_media_item(mediaItemId, connection)
11811224
if media_item is None or media_item.movieDuration is None:
@@ -1311,14 +1354,31 @@ def obtainSenderInfo(
13111354
lid_account = self.lidAccountIndex.account(jid)
13121355
if lid_account is not None:
13131356
profile_display_name = normalized_author_field(ProfilePushName.push_name(jid, connection))
1357+
linked_phone_jid = self.linkedPhoneJid(jid) or self.lidAccountIndex.phoneJid(jid)
1358+
linked_phone_display_name: str | None = None
1359+
if linked_phone_jid is not None:
1360+
linked_phone_display_name = self.resolvedContactDisplayName(
1361+
jid=linked_phone_jid,
1362+
profileDisplayName=normalized_author_field(ProfilePushName.push_name(linked_phone_jid, connection)),
1363+
senderPhone=normalized_author_field(extracted_phone(linked_phone_jid)),
1364+
connection=connection,
1365+
)
13141366
return (
1315-
profile_display_name,
1367+
linked_phone_display_name or profile_display_name,
13161368
normalized_author_field(lid_account.normalizedPhoneNumber) or sender_phone,
13171369
)
13181370

13191371
linked_phone_jid = self.linkedPhoneJid(jid)
13201372
if linked_phone_jid is not None:
1321-
return (None, normalized_author_field(extracted_phone(linked_phone_jid)))
1373+
return (
1374+
self.resolvedContactDisplayName(
1375+
jid=linked_phone_jid,
1376+
profileDisplayName=normalized_author_field(ProfilePushName.push_name(linked_phone_jid, connection)),
1377+
senderPhone=normalized_author_field(extracted_phone(linked_phone_jid)),
1378+
connection=connection,
1379+
),
1380+
normalized_author_field(extracted_phone(linked_phone_jid)),
1381+
)
13221382

13231383
push_name = ProfilePushName.push_name(jid, connection)
13241384
if push_name is not None:
@@ -1329,6 +1389,26 @@ def obtainSenderInfo(
13291389

13301390
return (contactNameGroupMember, sender_phone)
13311391

1392+
def resolvedContactDisplayName(
1393+
self,
1394+
jid: str,
1395+
profileDisplayName: str | None,
1396+
senderPhone: str | None,
1397+
connection: sqlite3.Connection,
1398+
) -> str | None:
1399+
chat_session_name = normalized_author_field(ChatSession.fetch_chat_session_name(jid, connection))
1400+
if chat_session_name is not None and not self.isPhoneLikeDisplayLabel(chat_session_name, senderPhone):
1401+
return chat_session_name
1402+
1403+
if self.addressBookIndex is not None:
1404+
address_book_contact = self.addressBookIndex.contact(jid)
1405+
if address_book_contact is not None:
1406+
display_name = normalized_author_field(address_book_contact.bestDisplayName)
1407+
if display_name is not None:
1408+
return display_name
1409+
1410+
return profileDisplayName
1411+
13321412
def makeParticipantAuthor(
13331413
self,
13341414
jid: str,
@@ -1525,14 +1605,13 @@ def buildContactList(
15251605
other_contact = self.copyContactMedia(other_contact, backup, directory)
15261606
contacts.append(other_contact)
15271607
else:
1528-
for member_id in GroupMember.fetch_group_member_ids(chatInfo.id, connection):
1529-
sender_info = self.fetchGroupMemberInfo(member_id, connection)
1530-
if sender_info is None:
1531-
continue
1532-
sender_name, sender_phone = sender_info
1533-
if sender_phone is None or sender_phone == owner_phone:
1608+
seen_phones = {owner_phone}
1609+
for member in self.fetchGroupContactMembers(chatInfo.id, connection):
1610+
sender_name, sender_phone = self.fetchResolvedGroupMemberInfo(member, connection)
1611+
if sender_phone is None or sender_phone == owner_phone or sender_phone in seen_phones:
15341612
continue
1535-
contact = ContactInfo(name=sender_name or "", phone=sender_phone)
1613+
seen_phones.add(sender_phone)
1614+
contact = ContactInfo(name=sender_name or sender_phone, phone=sender_phone)
15361615
if directory is not None:
15371616
contact = self.copyContactMedia(contact, backup, directory)
15381617
contacts.append(contact)

tests/support.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,154 @@ def setup(connection: sqlite3.Connection) -> None:
848848
return wa_backup, fixture
849849

850850

851+
def make_connected_active_group_members_backup() -> tuple[WABackup, TemporaryBackupFixture]:
852+
def setup(connection: sqlite3.Connection) -> None:
853+
create_common_tables(connection)
854+
connection.execute("ALTER TABLE ZWAGROUPMEMBER ADD COLUMN ZISACTIVE INTEGER")
855+
connection.execute("ALTER TABLE ZWAGROUPMEMBER ADD COLUMN ZCHATSESSION INTEGER")
856+
857+
latest = reference_date_timestamp(2024, 4, 11, 11, 0, 0)
858+
connection.execute(
859+
"""
860+
INSERT INTO ZWACHATSESSION
861+
(Z_PK, ZCONTACTJID, ZPARTNERNAME, ZLASTMESSAGEDATE, ZMESSAGECOUNTER, ZSESSIONTYPE, ZARCHIVED)
862+
VALUES (?, ?, ?, ?, ?, ?, ?)
863+
""",
864+
(710, "08185296380-999999@g.us", "Active Member Group", latest, 3, 0, 0),
865+
)
866+
connection.execute(
867+
"""
868+
INSERT INTO ZWACHATSESSION
869+
(Z_PK, ZCONTACTJID, ZPARTNERNAME, ZLASTMESSAGEDATE, ZMESSAGECOUNTER, ZSESSIONTYPE, ZARCHIVED)
870+
VALUES (?, ?, ?, ?, ?, ?, ?)
871+
""",
872+
(711, "08185296380@s.whatsapp.net", "Me", latest, 1, 0, 0),
873+
)
874+
875+
historical_members = [
876+
(901, "08185296378@s.whatsapp.net", "Alice Historical", 0, 710),
877+
(902, "40482648261001@lid", None, 0, 710),
878+
]
879+
connection.executemany(
880+
"""
881+
INSERT INTO ZWAGROUPMEMBER (Z_PK, ZMEMBERJID, ZCONTACTNAME, ZISACTIVE, ZCHATSESSION)
882+
VALUES (?, ?, ?, ?, ?)
883+
""",
884+
historical_members,
885+
)
886+
887+
active_members = [
888+
(911, "08185296380@s.whatsapp.net", None, 1, 710),
889+
(912, "08185296378@s.whatsapp.net", "Alice Active", 1, 710),
890+
(913, "40482648261001@lid", None, 1, 710),
891+
(914, "40482648261002@lid", None, 1, 710),
892+
]
893+
connection.executemany(
894+
"""
895+
INSERT INTO ZWAGROUPMEMBER (Z_PK, ZMEMBERJID, ZCONTACTNAME, ZISACTIVE, ZCHATSESSION)
896+
VALUES (?, ?, ?, ?, ?)
897+
""",
898+
active_members,
899+
)
900+
901+
push_names = [
902+
("Linked Delta", "08185296371@s.whatsapp.net"),
903+
("Nova Member", "08185296390@s.whatsapp.net"),
904+
]
905+
connection.executemany(
906+
"INSERT INTO ZWAPROFILEPUSHNAME (ZPUSHNAME, ZJID) VALUES (?, ?)",
907+
push_names,
908+
)
909+
910+
messages = [
911+
(
912+
710001,
913+
"08185296380-999999@g.us",
914+
0,
915+
901,
916+
710,
917+
"Historical Alice message",
918+
reference_date_timestamp(2024, 4, 11, 10, 0, 0),
919+
"08185296378@s.whatsapp.net",
920+
None,
921+
0,
922+
None,
923+
"active-group-1",
924+
),
925+
(
926+
710002,
927+
"08185296380-999999@g.us",
928+
0,
929+
902,
930+
710,
931+
"Historical Delta message",
932+
reference_date_timestamp(2024, 4, 11, 10, 5, 0),
933+
"40482648261001@lid",
934+
None,
935+
0,
936+
None,
937+
"active-group-2",
938+
),
939+
(
940+
710003,
941+
"08185296380-999999@g.us",
942+
0,
943+
None,
944+
710,
945+
"Outgoing",
946+
latest,
947+
None,
948+
None,
949+
1,
950+
None,
951+
"active-group-3",
952+
),
953+
(
954+
711001,
955+
"08185296380@s.whatsapp.net",
956+
6,
957+
None,
958+
711,
959+
None,
960+
reference_date_timestamp(2024, 4, 11, 9, 0, 0),
961+
None,
962+
None,
963+
1,
964+
None,
965+
"active-group-owner",
966+
),
967+
]
968+
connection.executemany(
969+
"""
970+
INSERT INTO ZWAMESSAGE
971+
(Z_PK, ZTOJID, ZMESSAGETYPE, ZGROUPMEMBER, ZCHATSESSION, ZTEXT, ZMESSAGEDATE, ZFROMJID, ZMEDIAITEM, ZISFROMME, ZGROUPEVENTTYPE, ZSTANZAID)
972+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
973+
""",
974+
messages,
975+
)
976+
977+
fixture = make_temporary_backup(name="active-group-members-backup", chat_storage_setup=setup)
978+
979+
add_lid_database(
980+
fixture,
981+
setup=lambda connection: connection.executemany(
982+
"""
983+
INSERT INTO ZWAZACCOUNT
984+
(Z_PK, ZIDENTIFIER, ZPHONENUMBER, ZCREATEDAT)
985+
VALUES (?, ?, ?, ?)
986+
""",
987+
[
988+
(1, "40482648261001@lid", "08185296371", reference_date_timestamp(2025, 2, 10, 12, 0, 0)),
989+
(2, "40482648261002@lid", "08185296390", reference_date_timestamp(2025, 2, 10, 12, 1, 0)),
990+
],
991+
),
992+
)
993+
994+
wa_backup = WABackup(backupPath=str(fixture.rootURL))
995+
wa_backup.connectChatStorageDb(fixture.backup)
996+
return wa_backup, fixture
997+
998+
851999
def make_connected_incomplete_location_backup() -> tuple[WABackup, TemporaryBackupFixture]:
8521000
def setup(connection: sqlite3.Connection) -> None:
8531001
create_common_tables(connection)

tests/test_public.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
MediaWriteDelegateSpy,
2828
add_lid_database,
2929
canonical_json,
30+
make_connected_active_group_members_backup,
3031
make_connected_filtered_chat_backup,
3132
make_connected_group_backup,
3233
make_connected_incomplete_location_backup,
@@ -561,3 +562,24 @@ def test_group_contact_list_contains_owner_and_distinct_members() -> None:
561562
assert len([contact for contact in dump.contacts if contact.name == "Me"]) == 1
562563
finally:
563564
remove_item_if_exists(fixture.rootURL)
565+
566+
567+
def test_group_contact_list_prefers_active_membership_and_deduplicates_history() -> None:
568+
wa_backup, fixture = make_connected_active_group_members_backup()
569+
try:
570+
dump = wa_backup.getChat(chatId=710, directoryToSaveMedia=None)
571+
contacts_by_phone = {contact.phone: contact for contact in dump.contacts}
572+
573+
assert len(dump.contacts) == 4
574+
assert set(contacts_by_phone) == {
575+
"08185296380",
576+
"08185296378",
577+
"08185296371",
578+
"08185296390",
579+
}
580+
assert contacts_by_phone["08185296380"].name == "Me"
581+
assert contacts_by_phone["08185296378"].name == "Alice Active"
582+
assert contacts_by_phone["08185296371"].name == "Linked Delta"
583+
assert contacts_by_phone["08185296390"].name == "Nova Member"
584+
finally:
585+
remove_item_if_exists(fixture.rootURL)

0 commit comments

Comments
 (0)