Skip to content

Commit 9cad5e3

Browse files
committed
feat(stickers): support sticker archiving on Android and gallery filtering on Android/iOS
1 parent 48c177a commit 9cad5e3

4 files changed

Lines changed: 246 additions & 1 deletion

File tree

‎tests/test_wa_chat_viewer.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ def test_gif(self):
175175

176176
def test_sticker(self):
177177
assert viewer._media_type_from_path("foo/Sticker/abc.webp") == "sticker"
178+
assert viewer._media_type_from_path("foo/stickers/abc.webp") == "sticker"
179+
assert (
180+
viewer._media_type_from_path("Groups/Chat/2026/STK-20260101-WA0001.webp") == "sticker"
181+
)
182+
assert viewer._media_type_from_path("Groups/Chat/2026/STK-20260101-WA0001.was") == "sticker"
178183

179184
def test_document(self):
180185
assert viewer._media_type_from_path("foo/document.pdf") == "document"
@@ -7057,3 +7062,160 @@ def test_frontend_assets_include_msg_edited(self):
70577062
css_content = css_path.read_text(encoding="utf-8")
70587063
assert "msg-edited" in js_content
70597064
assert "msg-edited" in css_content
7065+
7066+
7067+
class TestStickerSupport:
7068+
def test_ios_sticker_classification_and_gallery(self, tmp_path):
7069+
archive_path_db = tmp_path / ".wa_media_archiver.db"
7070+
archive_conn = make_archive_db(archive_path_db)
7071+
archive_conn.execute("INSERT INTO contacts (number, folder) VALUES ('44123456', 'Alice')")
7072+
archive_conn.execute(
7073+
"INSERT INTO archive_copies (original_path, archive_path) VALUES ('Message/Media/123/abc.webp', 'Contacts/Alice/2026/abc.webp')"
7074+
)
7075+
archive_conn.commit()
7076+
archive_conn.close()
7077+
7078+
wa_conn = sqlite3.connect(str(tmp_path / "ChatStorage.sqlite"))
7079+
wa_conn.executescript("""
7080+
CREATE TABLE ZWACHATSESSION (
7081+
Z_PK INTEGER PRIMARY KEY,
7082+
ZCONTACTJID TEXT,
7083+
ZGROUPINFO INTEGER,
7084+
ZPARTNERNAME TEXT,
7085+
ZHIDDEN INTEGER DEFAULT 0
7086+
);
7087+
CREATE TABLE ZWAMEDIAITEM (
7088+
Z_PK INTEGER PRIMARY KEY,
7089+
ZMEDIALOCALPATH TEXT,
7090+
ZTITLE TEXT,
7091+
ZVCARDNAME TEXT,
7092+
ZVCARDSTRING TEXT
7093+
);
7094+
CREATE TABLE ZWAGROUPMEMBER (
7095+
Z_PK INTEGER PRIMARY KEY,
7096+
ZMEMBERJID TEXT,
7097+
ZCONTACTNAME TEXT
7098+
);
7099+
CREATE TABLE ZWAPROFILEPUSHNAME (
7100+
ZJID TEXT,
7101+
ZPUSHNAME TEXT
7102+
);
7103+
CREATE TABLE ZWAMESSAGE (
7104+
Z_PK INTEGER PRIMARY KEY,
7105+
ZCHATSESSION INTEGER,
7106+
ZISFROMME INTEGER,
7107+
ZMESSAGEDATE REAL,
7108+
ZMESSAGETYPE INTEGER,
7109+
ZTEXT TEXT,
7110+
ZMEDIAITEM INTEGER,
7111+
ZFROMJID TEXT,
7112+
ZSTANZAID TEXT,
7113+
ZPARENTMESSAGE INTEGER,
7114+
ZPUSHNAME TEXT,
7115+
ZGROUPMEMBER INTEGER
7116+
);
7117+
""")
7118+
wa_conn.execute(
7119+
"INSERT INTO ZWACHATSESSION (Z_PK, ZCONTACTJID, ZGROUPINFO, ZPARTNERNAME) VALUES (1, '44123456@s.whatsapp.net', NULL, 'Alice')"
7120+
)
7121+
wa_conn.execute(
7122+
"INSERT INTO ZWAMEDIAITEM (Z_PK, ZMEDIALOCALPATH, ZTITLE) VALUES (1, 'Media/123/abc.webp', NULL)"
7123+
)
7124+
wa_conn.execute(
7125+
"INSERT INTO ZWAMESSAGE (Z_PK, ZCHATSESSION, ZISFROMME, ZMESSAGEDATE, ZMESSAGETYPE, ZTEXT, ZMEDIAITEM) VALUES (1, 1, 0, 700000000.0, 15, NULL, 1)"
7126+
)
7127+
wa_conn.commit()
7128+
wa_conn.close()
7129+
7130+
app = viewer.create_app(tmp_path, rescan=False)
7131+
with app.test_client() as client:
7132+
# 1. /api/messages classifies as sticker
7133+
resp = client.get("/api/messages?chat_id=1&chat_type=contact")
7134+
assert resp.status_code == 200
7135+
msgs = resp.get_json()
7136+
assert len(msgs) == 1
7137+
assert msgs[0]["media_type"] == "sticker"
7138+
assert msgs[0]["archive_path"] == "Contacts/Alice/2026/abc.webp"
7139+
7140+
# 2. /api/chats classifies last_msg_type as sticker
7141+
chats_resp = client.get("/api/chats")
7142+
assert chats_resp.status_code == 200
7143+
chats = chats_resp.get_json()
7144+
assert len(chats) == 1
7145+
assert chats[0]["last_msg_type"] == "sticker"
7146+
7147+
# 3. /api/media with types=sticker returns the sticker
7148+
stickers_resp = client.get("/api/media?chat_id=1&chat_type=contact&types=sticker")
7149+
assert stickers_resp.status_code == 200
7150+
stickers = stickers_resp.get_json()
7151+
assert len(stickers) == 1
7152+
assert stickers[0]["media_type"] == "sticker"
7153+
7154+
# 4. /api/media with types=image does NOT return the sticker
7155+
images_resp = client.get("/api/media?chat_id=1&chat_type=contact&types=image")
7156+
assert images_resp.status_code == 200
7157+
images = images_resp.get_json()
7158+
assert len(images) == 0
7159+
7160+
# 5. /api/media/count counts it as sticker
7161+
count_resp = client.get("/api/media/count?chat_id=1&chat_type=contact")
7162+
assert count_resp.status_code == 200
7163+
counts = count_resp.get_json()
7164+
assert counts["by_type"]["sticker"]["count"] == 1
7165+
assert counts["by_type"]["sticker"]["missing"] == 0
7166+
7167+
def test_android_sticker_classification_and_gallery(self, tmp_path):
7168+
wa_path = tmp_path / "msgstore.db"
7169+
archive_path_db = tmp_path / ".wa_media_archiver.db"
7170+
wa_conn = make_android_db(wa_path)
7171+
archive_conn = make_archive_db(archive_path_db)
7172+
seed_android_db(wa_conn, archive_conn)
7173+
7174+
wa_conn.execute(
7175+
"INSERT INTO message (_id, chat_row_id, from_me, sender_jid_row_id, timestamp, text_data, message_type) "
7176+
"VALUES (55, 10, 0, 1, 1700000055000, NULL, 20)"
7177+
)
7178+
wa_conn.execute(
7179+
"INSERT INTO message_media (message_row_id, file_path, media_name) "
7180+
"VALUES (55, 'Media/WhatsApp Stickers/STK-2026.webp', 'STK-2026.webp')"
7181+
)
7182+
wa_conn.commit()
7183+
7184+
archive_conn.execute(
7185+
"INSERT INTO archive_copies (original_path, archive_path) VALUES ('Media/WhatsApp Stickers/STK-2026.webp', 'Contacts/Alice/2026/STK-2026.webp')"
7186+
)
7187+
archive_conn.commit()
7188+
wa_conn.close()
7189+
archive_conn.close()
7190+
7191+
app = viewer.create_app(tmp_path, rescan=False)
7192+
with app.test_client() as client:
7193+
# 1. /api/messages classifies as sticker
7194+
resp = client.get("/api/messages?chat_id=123456789&chat_type=contact")
7195+
assert resp.status_code == 200
7196+
msgs = [m for m in resp.get_json() if m["msg_id"] == 55]
7197+
assert len(msgs) == 1
7198+
assert msgs[0]["media_type"] == "sticker"
7199+
assert msgs[0]["archive_path"] == "Contacts/Alice/2026/STK-2026.webp"
7200+
7201+
# 2. /api/media with types=sticker returns the sticker
7202+
stickers_resp = client.get(
7203+
"/api/media?chat_id=123456789&chat_type=contact&types=sticker"
7204+
)
7205+
assert stickers_resp.status_code == 200
7206+
stickers = stickers_resp.get_json()
7207+
assert len(stickers) == 1
7208+
assert stickers[0]["media_type"] == "sticker"
7209+
7210+
# 3. /api/media with types=image does NOT return the sticker
7211+
images_resp = client.get("/api/media?chat_id=123456789&chat_type=contact&types=image")
7212+
assert images_resp.status_code == 200
7213+
images = [img for img in images_resp.get_json() if img["msg_id"] == 55]
7214+
assert len(images) == 0
7215+
7216+
# 4. /api/media/count counts it as sticker
7217+
count_resp = client.get("/api/media/count?chat_id=123456789&chat_type=contact")
7218+
assert count_resp.status_code == 200
7219+
counts = count_resp.get_json()
7220+
assert counts["by_type"]["sticker"]["count"] == 1
7221+
assert counts["by_type"]["sticker"]["missing"] == 0

‎tests/test_wa_media_archiver.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,15 @@ def test_all_media_types_present(self):
157157
"Media/WhatsApp Video Notes/%",
158158
"Media/WhatsApp Animated Gifs/%",
159159
"Media/WhatsApp Documents/%",
160+
"Media/WhatsApp Stickers/%",
160161
]:
161162
assert path in query, f"Missing from query: {path}"
162163

164+
def test_stickers_included(self):
165+
query = android_handler.build_query(None, None, False)
166+
assert query.count("Media/WhatsApp Stickers/%") == 2
167+
assert "WhatsApp Stickers" in android_handler._MEDIA_SUBFOLDERS
168+
163169
def test_limit_clause_included(self):
164170
query = android_handler.build_query(limit=100, since_ms=None, hd_dedup=False)
165171
assert "LIMIT 50" in query # N//2 per block
@@ -271,6 +277,20 @@ def test_get_media_file_paths_hd_dedup(self):
271277
assert paths == {"Media/WhatsApp Images/hd.jpg"}
272278
conn.close()
273279

280+
def test_get_media_file_paths_includes_stickers(self):
281+
conn = _make_msgstore()
282+
conn.execute("INSERT INTO chat VALUES (1, NULL, 20)")
283+
conn.execute("INSERT INTO jid VALUES (20, '123456')")
284+
conn.execute("INSERT INTO message VALUES (1, 1000, 20, 0)")
285+
conn.execute(
286+
"INSERT INTO message_media VALUES ('Media/WhatsApp Stickers/STK-1.webp', 'image/webp', 1, 1, 'url', NULL)"
287+
)
288+
conn.commit()
289+
290+
paths = android_handler.get_media_file_paths(conn.cursor(), None, False)
291+
assert "Media/WhatsApp Stickers/STK-1.webp" in paths
292+
conn.close()
293+
274294

275295
# ===========================================================================
276296
# Schema validation (needs an in-memory SQLite DB)
@@ -1614,6 +1634,48 @@ def test_one_to_one_sent_routed_to_sent_folder(self, tmp_path, logger):
16141634
os.path.join(out, "Contacts", "Alice (00111)", "2024", "Sent", "img.jpg")
16151635
)
16161636

1637+
def test_sticker_archiving_group_and_contact(self, tmp_path, logger):
1638+
src_dir = tmp_path / "src"
1639+
stk_file = src_dir / "Media" / "WhatsApp Stickers" / "STK-20240115-WA0001.webp"
1640+
stk_file.parent.mkdir(parents=True, exist_ok=True)
1641+
stk_file.write_bytes(b"RIFF....WEBP")
1642+
out = str(tmp_path / "out")
1643+
os.makedirs(out)
1644+
rows = [
1645+
self._row(
1646+
file_path="Media/WhatsApp Stickers/STK-20240115-WA0001.webp",
1647+
chat_subject="Family",
1648+
sender="111",
1649+
key_from_me=0,
1650+
)
1651+
]
1652+
stats, _, _, _ = wa.process_rows(
1653+
rows,
1654+
1,
1655+
{"111": "Alice"},
1656+
{},
1657+
{},
1658+
{},
1659+
self._resolver(src_dir),
1660+
out,
1661+
logger,
1662+
False,
1663+
None,
1664+
None,
1665+
False,
1666+
{},
1667+
)
1668+
assert stats["copied"] == 1
1669+
assert os.path.isfile(
1670+
os.path.join(
1671+
out,
1672+
"Groups",
1673+
"Family",
1674+
"2024",
1675+
"STK-20240115-WA0001_Alice.webp",
1676+
)
1677+
)
1678+
16171679
def test_unknown_sender_falls_back_to_phone_number(self, tmp_path, logger):
16181680
src_dir = self._setup_src(tmp_path)
16191681
out = str(tmp_path / "out")

‎wab_archiver/android_handler.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"WhatsApp Video Notes",
3434
"WhatsApp Animated Gifs",
3535
"WhatsApp Documents",
36+
"WhatsApp Stickers",
3637
}
3738

3839

@@ -228,6 +229,7 @@ def build_query(limit: int | None, since_ms: int | None, hd_dedup: bool) -> str:
228229
OR message_media.file_path LIKE 'Media/WhatsApp Video Notes/%'
229230
OR message_media.file_path LIKE 'Media/WhatsApp Animated Gifs/%'
230231
OR message_media.file_path LIKE 'Media/WhatsApp Documents/%'
232+
OR message_media.file_path LIKE 'Media/WhatsApp Stickers/%'
231233
)
232234
AND chat.subject IS NOT NULL
233235
{since_clause}
@@ -263,6 +265,7 @@ def build_query(limit: int | None, since_ms: int | None, hd_dedup: bool) -> str:
263265
OR message_media.file_path LIKE 'Media/WhatsApp Video Notes/%'
264266
OR message_media.file_path LIKE 'Media/WhatsApp Animated Gifs/%'
265267
OR message_media.file_path LIKE 'Media/WhatsApp Documents/%'
268+
OR message_media.file_path LIKE 'Media/WhatsApp Stickers/%'
266269
)
267270
AND chat.subject IS NULL
268271
{since_clause}

‎wab_viewer/main.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,11 @@ def _media_type_from_path(path: str) -> str:
11771177
p = path.lower()
11781178
if p.endswith(".vcf"):
11791179
return "vcard"
1180-
if "sticker" in p or "stickers" in p:
1180+
if (
1181+
"sticker" in p
1182+
or "stickers" in p
1183+
or (os.path.basename(p).startswith("stk-") and (p.endswith(".webp") or p.endswith(".was")))
1184+
):
11811185
return "sticker"
11821186
ext = os.path.splitext(p)[1]
11831187
if ext in (".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"):
@@ -1705,6 +1709,7 @@ def _build_media_only(
17051709
CASE
17061710
WHEN m.ZMESSAGETYPE = 6 THEN 'service'
17071711
WHEN m.ZMESSAGETYPE = 4 THEN 'vcard'
1712+
WHEN m.ZMESSAGETYPE = 15 THEN 'sticker'
17081713
WHEN m.ZMESSAGETYPE IS NULL OR m.ZMESSAGETYPE = 0 THEN
17091714
CASE WHEN m.ZTEXT IS NOT NULL
17101715
AND (INSTR(LOWER(m.ZTEXT), 'http://') > 0
@@ -2683,6 +2688,7 @@ def get_cache():
26832688
CASE
26842689
WHEN m.ZMESSAGETYPE = 6 THEN 'service'
26852690
WHEN m.ZMESSAGETYPE = 4 THEN 'vcard'
2691+
WHEN m.ZMESSAGETYPE = 15 THEN 'sticker'
26862692
WHEN m.ZMESSAGETYPE IS NULL OR m.ZMESSAGETYPE = 0 THEN
26872693
CASE WHEN m.ZTEXT IS NOT NULL
26882694
AND (INSTR(LOWER(m.ZTEXT), 'http://') > 0
@@ -2826,6 +2832,7 @@ def get_cache():
28262832
CASE
28272833
WHEN m.message_type = 7 THEN 'service'
28282834
WHEN m.message_type IN (4, 14) THEN 'vcard'
2835+
WHEN m.message_type = 20 THEN 'sticker'
28292836
WHEN m.message_type IS NULL OR m.message_type = 0 THEN
28302837
CASE WHEN m.text_data IS NOT NULL
28312838
AND (INSTR(LOWER(m.text_data), 'http://') > 0
@@ -3212,6 +3219,10 @@ def api_chats():
32123219
source_type == "ios" and raw_type == 4
32133220
):
32143221
d["last_msg_type"] = "vcard"
3222+
elif (source_type == "android" and raw_type == 20) or (
3223+
source_type == "ios" and raw_type == 15
3224+
):
3225+
d["last_msg_type"] = "sticker"
32153226
elif raw_type != 0 and media_path:
32163227
path = media_path if source_type == "android" else f"Message/{media_path}"
32173228
d["last_msg_type"] = _media_type_from_path(path)
@@ -3510,6 +3521,13 @@ def api_media():
35103521
ext_conds.append("m.message_type IN (4, 14)")
35113522
else:
35123523
ext_conds.append("m.ZMESSAGETYPE = 4")
3524+
if mt == "sticker":
3525+
if source_type == "android":
3526+
ext_conds.append(
3527+
"(m.message_type = 20 OR mm.file_path LIKE '%sticker%')"
3528+
)
3529+
else:
3530+
ext_conds.append("m.ZMESSAGETYPE = 15")
35133531
if ext_conds:
35143532
prune_sql = " OR ".join(ext_conds)
35153533
cand_clauses.append(

0 commit comments

Comments
 (0)