Files

665 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""微信 4.x 媒体文件读取与下载(图片解密、语音、视频、文件)。
与 :mod:`wechatauto.db` 配合使用:``db`` 提供解密后的消息行(含 local_type、
server_id、packed_info),本模块负责把媒体内容从本地取回/解密/落地。
支持的媒体(local_type 见 :data:`wechatauto.db.MSG_TYPE_NAMES`::
local_type 3 图片 → 会话目录 msg/attach/<会话md5>/<YYYY-MM>/Img/<md5>.dat
local_type 34 语音 → message/media_0.db VoiceInfo.voice_dataSILK 二进制)
local_type 43 视频 → msg/video/<YYYY-MM>/<id>.mp4(未落地时返回 None
local_type 49 文件 → msg/file/<YYYY-MM>/<原文件名>,原名取自
message_resource.db MessageResourceDetail.packed_info
图片加密(v2 格式,本库已在本机验证)::
结构: [6B sig 07 08 56 32 08 07][4B aes_size LE][4B xor_size LE][1B pad]
[aes 密文(ECB, PKCS7, 对齐 16B)][raw 明文][xor 密文]
- AES 密钥: 16 字节 ASCII(字母/数字),仅在 Weixin.exe 进程内存中。
通过 AES-ECB 解首块密文、校验 JPEG/PNG 魔数反推出(内存正则扫描)。
- XOR 密钥: 单字节,从同图缩略图 ``<md5>_t.dat`` 尾部 JPEG 结束标记
``FF D9`` 反推(``key = tail[0] ^ 0xFF``)。
用法::
from wechatauto import WeChatDB, MediaDownloader
db = WeChatDB()
md = MediaDownloader(db)
md.download_media(chat_user, msg_row["local_id"]) # 按类型自动分发
"""
from __future__ import annotations
import ctypes
import glob
import hashlib
import json
import os
import re
import struct
import tempfile
import time
from typing import List, Optional, Tuple
V1_MAGIC = b"\x07\x08\x05\x56\x02\x05"
V2_MAGIC = b"\x07\x08\x56\x32\x08\x07"
V1_HEADER_SZ = 22 # 6B sig + 16B xor key
AES16_RE = re.compile(rb"[0-9a-zA-Z]{16,32}")
DEFAULT_SAVE_PATH = os.path.join(os.path.expanduser("~"), "Documents", "wechatauto_media")
def _jpeg_like(pt: bytes) -> bool:
return (
(pt[:3] == b"\xff\xd8\xff")
or pt[:4] in (b"\x89PNG", b"GIF8", b"RIFF")
or pt[:4] == b"wxgf" # 微信动画表情容器
)
def aligned_aes_block_size(aes_size: int) -> int:
return aes_size + (16 - aes_size % 16) if aes_size % 16 else aes_size + 16
class MediaDownloader:
"""微信 4.x 媒体下载器"""
def __init__(self, db, save_dir: Optional[str] = None,
image_key: Optional[str] = None,
cfg_dword: Optional[int] = None):
self.db = db
self.save_dir = save_dir or DEFAULT_SAVE_PATH
self._image_key = image_key # 显式注入的图片 AES 密钥
self._cfg_dword = cfg_dword # cfg+0x40, 派生图片密钥(最佳方案)
self._xor_key: Optional[int] = None
self._img_key: Optional[Tuple[str, int]] = None
self._key_probe: Optional[bytes] = None
@staticmethod
def derive_image_keys(cfg_dword: int, wxid: str) -> Tuple[str, int]:
"""cfgDword 派生图片密钥(微信 4.x 最佳方案, 实测 3000/3000 验证)。
imageXorKey = cfgDword & 0xFF
imageAesKey = MD5(str(cfgDword) + wxid)[:16] # 前 16 位即真 AES-128 密钥
"""
xor_key = cfg_dword & 0xFF
aes_key = hashlib.md5(
("%d" % cfg_dword + wxid).encode("utf-8")).hexdigest()[:16]
return aes_key, xor_key
def _derive_cfg_key(self) -> Optional[Tuple[str, int]]:
"""cfgDword 派生并验证; 优先显式注入, 否则用 db.cfg_dword(自动提取)。"""
cfg_dword = self._cfg_dword
if cfg_dword is None:
cfg_dword = getattr(self.db, "cfg_dword", None)
if not cfg_dword:
return None
aes_key, xor_key = self.derive_image_keys(cfg_dword, self.db.wxid)
if self._validate_key(aes_key):
return aes_key, xor_key
return None
# ------------------------------------------------------------------
# 图片密钥(内存扫描 + 缩略图反推)
# ------------------------------------------------------------------
def _probe_ct(self, dat_path: Optional[str] = None) -> bytes:
"""取一张 V2 图片的密文首块,作为密钥反测试样"""
if self._key_probe is not None:
return self._key_probe
if dat_path is None:
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*.dat"))
if not hits:
return b""
dat_path = hits[0]
with open(dat_path, "rb") as f:
head = f.read(32)
if head[:6] == V2_MAGIC:
self._key_probe = head[15:31]
else:
self._key_probe = head[V1_HEADER_SZ: V1_HEADER_SZ + 16]
return self._key_probe
def _validate_key(self, aes_key: str) -> bool:
"""用真实密文首块反测密钥是否有效"""
probe = self._probe_ct()
if not probe:
return False
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
dec = Cipher(algorithms.AES(aes_key.encode()), modes.ECB()).decryptor()
return _jpeg_like(dec.update(probe) + dec.finalize())
except Exception:
return False
def _key_store(self) -> str:
return os.path.join(self.db.workdir, "image_keys.json")
def _load_persisted_key(self) -> Optional[str]:
try:
with open(self._key_store(), "r", encoding="utf-8") as f:
saved = json.load(f)
key = saved.get(self.db.account)
except (OSError, ValueError, json.JSONDecodeError):
return None
if key and self._validate_key(key):
return key
return None
def _persist_key(self, aes_key: str) -> None:
try:
with open(self._key_store(), "r", encoding="utf-8") as f:
saved = json.load(f)
except (OSError, ValueError, json.JSONDecodeError):
saved = {}
saved[self.db.account] = aes_key
try:
os.makedirs(os.path.dirname(self._key_store()), exist_ok=True)
with open(self._key_store(), "w", encoding="utf-8") as f:
json.dump(saved, f, indent=2)
except OSError:
pass
def _collect_templates(self, limit: int = 32, keep: int = 16) -> List[str]:
"""递归收集 *_t.dat 缩略图模板: 按修改时间降序取前 keep 个"""
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*_t.dat"))
hits.sort(key=os.path.getmtime, reverse=True)
return hits[:keep]
def _get_xor_key(self, templates: List[str]) -> Optional[int]:
"""文件尾统计推 XOR 密钥: 缩略图明文为 JPEG, 尾部固定 FF D9。
读每个模板最后 2 字节 (x, y), 统计出现最多的组合;
xorKey = x ^ 0xFF 且校验 y ^ 0xD9 == xorKey 才返回。
"""
tails: Dict[Tuple[int, int], int] = {}
for p in templates:
try:
with open(p, "rb") as f:
f.seek(-2, 2)
tail = f.read(2)
except OSError:
continue
if len(tail) == 2:
tails[(tail[0], tail[1])] = tails.get((tail[0], tail[1]), 0) + 1
for (x, y), _ in sorted(tails.items(), key=lambda kv: -kv[1]):
key = x ^ 0xFF
if y ^ 0xD9 == key:
return key
return None
def _derive_xor_key(self, dat_path: str) -> Optional[int]:
"""从单个 .dat 文件尾部反推 XOR 密钥(JPEG 尾 FF D9 被 XOR 加密)。
key = tail[0] ^ 0xFF,并校验 tail[1] ^ 0xD9 == key。
"""
try:
with open(dat_path, "rb") as f:
f.seek(-2, 2)
tail = f.read(2)
except OSError:
return None
if len(tail) == 2:
key = tail[0] ^ 0xFF
if tail[1] ^ 0xD9 == key:
return key
return None
def _dbg_last_dat(self) -> Optional[str]:
"""返回最近修改的一张 .dat 图片缓存文件路径(用于 XOR 密钥兜底推导)。"""
base = os.path.join(self.db.account_dir, "msg", "attach")
hits = glob.glob(os.path.join(base, "*", "*", "Img", "*.dat"))
if not hits:
return None
hits.sort(key=os.path.getmtime, reverse=True)
return hits[0]
def _scan_aes_key(self, monitor: bool = False,
monitor_timeout: float = 120.0) -> Optional[str]:
"""扫描 Weixin.exe 内存穷举候选密钥, AES-ECB 解密探针验证。
候选两类模式(YARA 思路):
- ASCII: 非字母数字 + 连续 32 个 [a-zA-Z0-9] + 非字母数字结尾,
每候选取前 16 字节作 AES-128 key 解密探针, 明文为 JPEG SOI 命中;
- UTF-16LE: 字母数字与 0x00 交错形式。
"""
probe = self._probe_ct()
if not probe:
return None
pids = self.db._find_weixin_pids()
if not pids:
return None
from . import db as _dbmod
k32 = _dbmod._k32
MBI = _dbmod._MBI
ASCII32_RE = re.compile(rb"[^a-zA-Z0-9]([a-zA-Z0-9]{32})[^a-zA-Z0-9]")
U16_RE = re.compile(rb"(?:[a-zA-Z0-9]\x00){32}")
def read_mem(h, addr: int, n: int):
buf = ctypes.create_string_buffer(n)
br = ctypes.c_size_t(0)
if k32.ReadProcessMemory(h, ctypes.c_void_p(addr), buf, n, ctypes.byref(br)) and br.value:
return buf.raw[: br.value]
return None
def _try_key(key16: bytes) -> bool:
try:
from cryptography.hazmat.primitives.ciphers import (
Cipher, algorithms, modes,
)
pt = Cipher(algorithms.AES(key16), modes.ECB()).decryptor()
out = pt.update(probe) + pt.finalize()
except Exception:
return False
return out[:3] == b"\xff\xd8\xff" or _jpeg_like(out)
def _candidates(buf: bytes):
for m in ASCII32_RE.finditer(buf):
yield m.group(1)[:16].encode() if isinstance(m.group(1), str) else m.group(1)[:16]
for m in U16_RE.finditer(buf):
yield bytes(b for i, b in enumerate(m.group()) if i % 2 == 0)[:16]
def _scan_once() -> Optional[str]:
# 保持微信进程原顺序扫描(主进程靠前, 命中率高)
for pid in pids:
h = k32.OpenProcess(0x0010 | 0x0400, False, pid)
if not h:
continue
try:
addr = 0
while True:
mbi = MBI()
r = k32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi))
if r == 0:
break
if (
mbi.State == 0x1000
and (mbi.Protect & 0xFF) & 0xE6
and not (mbi.Protect & 0x100)
and 0 < mbi.RegionSize < 0x2000000
):
buf = read_mem(h, mbi.BaseAddress or 0, mbi.RegionSize)
if buf:
for key in _candidates(buf):
if _try_key(key):
return key.decode("ascii", "replace")
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
finally:
k32.CloseHandle(h)
return None
found = _scan_once()
if found or not monitor:
return found
print(
"未在微信进程内存中找到图片 AES 密钥。\n"
"请现在打开微信,进入任意聊天,点击一张图片查看大图,\n"
f"本程序将在 {monitor_timeout:.0f} 秒内自动捕获密钥..."
)
start = time.time()
while time.time() - start < monitor_timeout:
time.sleep(2.0)
found = _scan_once()
if found:
return found
return None
def detect_image_key(self, refresh: bool = False) -> Optional[Tuple[str, int]]:
"""返回 (AES 密钥, XOR 密钥);失败返回 None。结果缓存,refresh=True 强制重扫。
总流程:
定位缓存目录 → 收集 *_t.dat 模板 → 文件尾推 XOR 密钥(众数统计)
→ 文件头取 AES 密文 → cfgDword 派生 / 扫描 Weixin.exe 内存穷举
候选密钥 → AES-ECB 解密验证(JPEG SOI)。
密钥来源优先级:cfgDword 派生(确定性离线, 免看图驻留) → 显式注入
image_key → 本地缓存 → 进程内存扫描。命中后持久化, 下次免扫。
"""
if self._img_key and not refresh:
return self._img_key
probe = self._probe_ct()
if not probe:
return None
templates = self._collect_templates()
# 1) XOR: 模板文件尾众数统计
xor_key = self._get_xor_key(templates)
if xor_key is None:
dat = self._dbg_last_dat()
xor_key = self._derive_xor_key(dat) if dat else 0x88
# 2) AES: cfgDword 派生优先
derived = self._derive_cfg_key()
if derived:
self._img_key = (derived[0], xor_key)
return self._img_key
aes_key = None
if self._image_key and self._validate_key(self._image_key):
aes_key = self._image_key
if not aes_key:
aes_key = self._load_persisted_key()
if not aes_key:
aes_key = self._scan_aes_key(monitor=True)
if aes_key:
self._persist_key(aes_key)
if not aes_key:
return None
self._img_key = (aes_key, xor_key)
return self._img_key
# ------------------------------------------------------------------
# 图片解密
# ------------------------------------------------------------------
def decrypt_image(self, dat_path: str, aes_key: Optional[str] = None,
xor_key: Optional[int] = None) -> bytes:
"""解密单个 .dat 为图片字节(自动识别 v1/v2 格式)"""
with open(dat_path, "rb") as f:
data = f.read()
if not data:
raise ValueError("空文件: %s" % dat_path)
magic = data[:6]
if magic == V2_MAGIC:
return self._decrypt_v2(data, dat_path, aes_key, xor_key)
if magic == V1_MAGIC:
if xor_key is None:
xor_key = self._derive_xor_key(dat_path)
key = data[6:22]
body = data[22:]
return bytes(b ^ (xor_key & 0xFF) for b in body)
# 早期纯异或格式:逐字节 ^ 0xFF(无签名),按 JPEG/PNG 魔数回退判断
for cand in (0x88, 0x30, 0xFF, 0xE9):
out = bytes(b ^ cand for b in data)
if out[:3] == b"\xff\xd8\xff" or out[:4] == b"\x89PNG":
return out
raise ValueError("无法识别的图片加密格式: %s" % dat_path)
def _resolve_aes_key(self) -> Optional[str]:
"""统一密钥解析:显式注入 → 本地缓存 → 内存扫描"""
if self._image_key and self._validate_key(self._image_key):
return self._image_key
cached = self._load_persisted_key()
if cached:
return cached
key = self._scan_aes_key(monitor=True)
if key:
self._persist_key(key)
return key
def _decrypt_v2(self, data: bytes, dat_path: str,
aes_key: Optional[str], xor_key: Optional[int]) -> bytes:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
aes_size, xor_size = struct.unpack_from("<LL", data, 6)
if xor_key is None:
xor_key = self._derive_xor_key(dat_path)
if aes_key is None:
aes_key = self._resolve_aes_key()
if not aes_key:
raise RuntimeError(
"无法获取图片 AES 密钥:请保持微信登录,并先在微信聊天中"
"打开(点击查看大图)任意一张图片,再重试 detect_image_key()"
"或通过 MediaDownloader(image_key='...') 手动传入密钥。"
)
aes_blk = aligned_aes_block_size(aes_size)
off = 15
aes_data = data[off: off + aes_blk]
off += aes_blk
raw_data = data[off: len(data) - xor_size]
xor_data = data[len(data) - xor_size:]
dec = Cipher(algorithms.AES(aes_key.encode()), modes.ECB()).decryptor()
pt = dec.update(aes_data) + dec.finalize()
pad = pt[-1] if pt else 0
if 1 <= pad <= 16 and all(b == pad for b in pt[-pad:]):
pt = pt[:-pad]
return pt + raw_data + bytes(b ^ (xor_key & 0xFF) for b in xor_data)
# ------------------------------------------------------------------
# 定位本地文件
# ------------------------------------------------------------------
def _chat_md5(self, user: str) -> str:
import hashlib
return hashlib.md5(user.encode()).hexdigest()
def _month_of(self, create_time: int) -> str:
return time.strftime("%Y-%m", time.localtime(create_time))
def _find_dat(self, user: str, md5: str, create_time: int,
thumbnail: bool = False) -> Optional[str]:
base = os.path.join(self.db.account_dir, "msg", "attach", self._chat_md5(user))
target = md5 + ("_t.dat" if thumbnail else ".dat")
for root, _, files in os.walk(base):
for f in files:
if f == target:
return os.path.join(root, f)
return None
# ------------------------------------------------------------------
# 各类媒体下载
# ------------------------------------------------------------------
def _out(self, save_dir: Optional[str], name: str) -> str:
d = save_dir or self.save_dir
os.makedirs(d, exist_ok=True)
return os.path.join(d, name)
# ------------------------------------------------------------------
# WXAM (wxgf) 解码:微信 4.x 普通图片的新存储格式,内部为 HEVC 裸流
# ------------------------------------------------------------------
def _extract_hevc(self, data: bytes) -> Optional[bytes]:
"""从 wxgf 容器提取 HEVC Annex-B 裸流(自首个 NALU 起始码起)。"""
start = data.find(b"\x00\x00\x00\x01")
return data[start:] if start >= 0 else None
@staticmethod
def _ffmpeg_exe() -> Optional[str]:
import shutil
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
return None
def _wxgf_to_jpg(self, data: bytes) -> Optional[bytes]:
"""用 ffmpeg 把 wxgf 内的 HEVC 裸流转码为 jpg。失败返回 None。"""
exe = self._ffmpeg_exe()
if exe is None:
return None
hevc = self._extract_hevc(data)
if not hevc:
return None
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "in.hevc")
dst = os.path.join(td, "out.jpg")
with open(src, "wb") as f:
f.write(hevc)
try:
r = subprocess.run(
[exe, "-y", "-v", "error", "-i", src, "-frames:v", "1", dst],
capture_output=True, timeout=30,
)
except Exception:
return None
if r.returncode == 0:
try:
with open(dst, "rb") as f:
out = f.read()
return out if out[:3] == b"\xff\xd8\xff" else None
except OSError:
return None
return None
def _img_md5(self, row: dict) -> Optional[str]:
pi = row.get("packed_info")
content = row.get("content")
for blob in (pi, content):
if isinstance(blob, bytes):
m = re.search(rb"([0-9a-fA-F]{32})", blob)
if m:
return m.group(1).decode().lower()
return None
def download_image(self, user: str, local_id: int, save_dir: Optional[str] = None,
aes_key: Optional[str] = None, xor_key: Optional[int] = None) -> Optional[str]:
"""下载图片消息并解密为 jpg/png/gif,返回落盘路径"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 3:
return None
md5 = self._img_md5(row)
if not md5:
return None
dat_path = self._find_dat(user, md5, row["create_time"])
thumb = False
if not dat_path:
# 群聊图片默认只有缩略图(原图未在微信中点开查看时不下发),回退缩略图
dat_path = self._find_dat(user, md5, row["create_time"], thumbnail=True)
if not dat_path:
return None
thumb = True
data = self.decrypt_image(dat_path, aes_key, xor_key)
suffix = "_thumb" if thumb else ""
if data[:3] == b"\xff\xd8\xff":
ext = "jpg"
elif data[:4] == b"\x89PNG":
ext = "png"
elif data[:3] == b"GIF":
ext = "gif"
elif data[:4] == b"wxgf":
# WXAM 格式:微信 4.x 普通图片也用 HEVC 编码存储(含动画表情)。
# 优先用 ffmpeg 转码为 jpg;不可用时把原始解密数据落盘为 .wxgf 兜底。
jpg = self._wxgf_to_jpg(data)
if jpg is not None:
out = self._out(save_dir, "%s_%s%s.%s" % (user, local_id, suffix, "jpg"))
with open(out, "wb") as f:
f.write(jpg)
return out
out = self._out(save_dir, "%s_%s%s.wxgf" % (user, local_id, suffix))
with open(out, "wb") as f:
f.write(data)
return out
else:
ext = "img"
out = self._out(save_dir, "%s_%s%s.%s" % (user, local_id, suffix, ext))
with open(out, "wb") as f:
f.write(data)
return out
def download_voice(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""语音:media_*.db VoiceInfo.voice_dataSILK 二进制),落盘 .silk
微信按账号/时间把语音分片存到多个 media_*.db,逐个搜索直到找到。
"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 34 or not row["server_id"]:
return None
for rel, path, _ in self.db._db_files:
if not os.path.basename(path).startswith("media_"):
continue
conn = self.db._open(rel)
try:
cid = conn.execute(
"SELECT rowid FROM Name2Id WHERE user_name=?", (user,)
).fetchone()
chat_id = cid[0] if cid else None
if chat_id is None:
continue
v = conn.execute(
"SELECT voice_data FROM VoiceInfo WHERE chat_name_id=? AND svr_id=? "
"ORDER BY create_time DESC LIMIT 1",
(chat_id, row["server_id"]),
).fetchone()
finally:
conn.close()
if v and v["voice_data"]:
out = self._out(save_dir, "%s_%s.silk" % (user, local_id))
with open(out, "wb") as f:
f.write(v["voice_data"])
return out
return None
def download_video(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""视频:按 packed_info 中的 id 在 msg/video 下查找 <id>.mp4"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 43:
return None
pi = row.get("packed_info")
if not isinstance(pi, bytes):
return None
m = re.search(rb"([0-9a-fA-F]{32})", pi)
vid = m.group(1).decode().lower() if m else None
base = os.path.join(self.db.account_dir, "msg", "video")
for root, _, files in os.walk(base):
for f in files:
if vid and f == vid + ".mp4":
out = self._out(save_dir, "%s_%s.mp4" % (user, local_id))
with open(out, "wb") as w:
with open(os.path.join(root, f), "rb") as r:
w.write(r.read())
return out
return None
def _file_name(self, row: dict) -> Optional[str]:
if not row["server_id"]:
return None
for rel, path, _ in self.db._db_files:
if os.path.basename(path) != "message_resource.db":
continue
conn = self.db._open(rel)
try:
r = conn.execute(
"SELECT d.packed_info FROM MessageResourceDetail d "
"LEFT JOIN MessageResourceInfo i ON d.message_id=i.message_id "
"WHERE i.message_svr_id=? LIMIT 1",
(row["server_id"],),
).fetchone()
finally:
conn.close()
if r and r["packed_info"]:
name = r["packed_info"].decode("utf-8", "replace").strip()
name = re.sub(r"[\r\n\x00]+", "", name)
if "/" in name or "\\" in name:
name = name.split("/")[-1].split("\\")[-1]
return name or None
break
return None
def download_file(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""文件:msg/file/<YYYY-MM>/<原文件名>,原文件名来自 message_resource"""
row = self.db.get_message_row(user, local_id)
if not row or row["local_type"] != 49:
return None
name = self._file_name(row)
if not name:
return None
base = os.path.join(self.db.account_dir, "msg", "file")
for root, _, files in os.walk(base):
for f in files:
if f == name:
out = self._out(save_dir, "%s_%s_%s" % (user, local_id, name))
with open(out, "wb") as w:
with open(os.path.join(root, f), "rb") as r:
w.write(r.read())
return out
return None
def download_media(self, user: str, local_id: int, save_dir: Optional[str] = None) -> Optional[str]:
"""按消息类型自动分发:3 图片 / 34 语音 / 43 视频 / 49 文件"""
row = self.db.get_message_row(user, local_id)
if not row:
return None
t = row["local_type"]
if t == 3:
return self.download_image(user, local_id, save_dir)
if t == 34:
return self.download_voice(user, local_id, save_dir)
if t == 43:
return self.download_video(user, local_id, save_dir)
if t == 49:
return self.download_file(user, local_id, save_dir)
return None