Background

Here’s how it happened.

I have a habit of exporting and backing up my QQ chat history every so often. Not for any particular reason—just afraid that one day the account will be gone, and the chat history will be gone too.

Previously I had always used a tool called QQFlow, written in Electron + Python, and it worked. But there were two problems: first, the software was too large—the installer was 188MB; second, opening a large database was painfully slow—a 190MB database took several minutes.

One night I was waiting for it to load again, watching the progress bar not move at all, and suddenly thought: why not rewrite one in Rust?

It wasn’t actually that hard

Saying “rewrite” somewhat overestimates me.

What I did was more like “integration.” The core idea was based on the architecture of WeFlow, a WeChat chat history export tool—load all messages into an in-memory cache at once; subsequent queries go directly to memory and no longer touch the database.

The specific implementation was like this:

  1. Key extraction: inject into the QQ process using the Windows Debug API and find the SQLCipher encryption key in memory. There was ready-made code for this part online; I modified it a bit and used it.

  2. Database decryption: use rusqlite + bundled-sqlcipher to stream-copy a decrypted copy of the database to a temporary directory. It is copied only once; afterward, the cache is read directly.

  3. Message loading: SELECT * full table scan, storing all messages grouped by group number/private chat target in a HashMap. A 190MB database takes about 30-60 seconds; after that, all queries are O(1).

  4. Frontend: React + TypeScript + Vite; I copied styles from a few open-source projects and pieced them together.

To be honest, I didn’t write much original code. Most of the time was spent debugging, stepping into pitfalls, and reading error logs.

Pitfalls I encountered

Pitfall 1: Deadlock

The first version had a very strange bug: clicking group chat analysis always showed “Loading…”.

It took two days of debugging to discover it was a deadlock. load_store_if_needed performed database operations while holding a Mutex lock, while another thread was also waiting for that lock. The two threads waited on each other and got stuck.

The solution was to separate “checking the cache” from “performing database operations”: first check the cache, release the lock, perform the database operation, then acquire the lock again to store. Sounds simple, but it took me three revisions to stabilize it.

Pitfall 2: BLOB parsing

QQ message content is stored in a BLOB field as Protobuf-encoded binary data. An early version of the parser decoded varint bytes into CJK Extension Area characters, outputting a bunch of “obscure character” garbage.

Later I added a purity filter: keep it only if more than 60% of the characters are common Chinese characters. But this threshold was too high, and English messages would be filtered out. It was only solved by first trying to extract readable text, then falling back to ASCII classification.

There was an even more absurd problem: some binary BLOBs would send the parser into an infinite loop, maxing out CPU at 100%. In the end, I added an operation budget (budget = blob_size × 50) and automatically fell back when the budget was exceeded, which finally fixed it.

Pitfall 3: UID mapping

QQ senders are not stored directly as QQ numbers but as a UID (something like u_-PBswiplK-7J7bmaQLA-mA). The QQ number is stored in another mapping table.

The problem was that this mapping table’s column names were all numbers (48901, 48902, 1002) and had no semantics. My original logic was to find the first non-numeric column as the UID and the first numeric column as the QQ number, but the values in the first column were purely numeric sequence numbers and were misidentified as QQ numbers.

In the end, I wrote a 4-level lookup strategy: direct lookup by known column name → automatic detection of candidate tables → fuzzy table-name search → brute-force full-table scan. Only then did it become compatible with mapping tables from various QQ versions.

Pitfall 4: CSV garbled text

When the exported CSV file was opened in Excel, all Chinese text was garbled. After investigating for quite a while, I found it was an encoding issue—UTF-8 had no BOM header, and Excel parsed it as GBK by default.

I added a 3-byte BOM (\xEF\xBB\xBF) at the beginning of the file, and the problem was solved. These kinds of small issues are the most annoying.

Final results

Feature Original (Electron + Python) Rust version (Tauri)
Installer size ~188MB ~10MB
Memory usage ~200MB ~30MB
Opening a 190MB database Several minutes 30-60 seconds (first time), instant afterward

Feature list

  • Key extraction (Windows Debug API)
  • Database decryption (SQLCipher)
  • TXT export (sorted by time, grouped by date)
  • CSV export (with BOM, can be opened directly in Excel)
  • Chat analysis (24h distribution, type pie chart, member ranking, high-frequency phrases)
  • Multi-account support (independent cache per QQ number)
  • Dark/light themes

Open source

Project address: yfgug/QQFlow

Currently it only supports Windows because key extraction uses the Windows API. On Mac and Linux, the key extraction method is different, and I haven’t looked into it yet.

Final Thoughts

I worked on this project for two months from start to finish. It’s called a “rewrite,” but most of the time was actually spent debugging. Rust’s compile errors are really headache-inducing; sometimes a lifetime issue could block me for half a day.

But I still finished it in the end.

My biggest takeaway after finishing was: many times you don’t need to build wheels from scratch. See how others do it, take the good ideas, and implement them once in your own way—the process itself is learning.

Thanks to the author of WeFlow for their open-source contribution. Without their project, I probably wouldn’t have thought of using this architecture.

“Code is the art of integration, not a patent of originality.”