diff --git a/README.md b/README.md index 32bb539..e05a6c4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 在 js 端屏蔽飞书消息的已读回执。即,自己在看到其他人发送的(私聊或群聊)普通消息后, 对方的视角也会保持在自己未读的状态。 +已验证版本:macOS 飞书 7.53.16。 + ## 使用方法 @@ -17,24 +19,90 @@ python main.py <飞书安装根目录> 脚本会解压 `webcontent/messenger.asar` 文件并修改里面的 js 文件。 -修改成功后请重启飞书客户端。若发现飞书功能异常,请将脚本生成的 `messenger.asar` 目录删除, -并将 `messenger.asar.bak` 文件重命名回 `messenger.asar`. +修改成功后请重启飞书客户端。若发现飞书功能异常,请将修改后的 `messenger.asar` 删除, +并将 `messenger.asar.bak` 文件重命名回 `messenger.asar`。 + +若已经应用过旧补丁,需要基于现有原始备份重新生成补丁,可执行: + +```bash +python main.py <飞书安装根目录> --repatch +``` + + +## 背景 + +原有实现会在 `updateMessagesMeRead` 调用前清空 `messageIds`,从而阻止普通浏览触发 +已读回执。但在新版客户端中,已读请求除了消息 ID,还包含以下位置字段: + +- `maxPosition` / `maxPositionBadgeCount` +- `threadMaxPosition` / `threadMaxPositionBadgeCount` +- `foldIds` + +实测中,即使 `messageIds` 已被清空,只要这些会话或话题位置仍然上报,对方仍会在 +打开会话后立即看到已读。 + +另一方面,完全屏蔽已读也会产生一个明显的交互问题:用户已经回复消息,对方却仍然 +看到未读。因此,本项目会在发送成功后为当前会话短时放行一次已读上报。 + + +## 实现原理 + +### 完整拦截普通浏览产生的已读请求 + +未获得发送许可时,不再只清空 `messageIds`,而是将本次上报中的所有已读信息恢复为 +无操作状态: + +- 清空 `messageIds` 和 `foldIds` +- 将会话位置恢复为 `maxPosition=-1`、`maxPositionBadgeCount=0` +- 清空话题 ID、话题位置和话题角标位置 + +客户端内部仍可以处理原始消息列表,但传给 native 层的请求不再携带可推进服务端 +已读状态的信息。 + +### 发送成功后按会话短时放行一次 + +在 `MessageService::sendMessage:onSendMessageSuccess:` 锚点设置一次性许可: + +- 许可绑定当前 `chatId`,其他会话无法消费 +- 许可有效期为 1.5 秒,避免很久之后的浏览请求意外放行 +- 只允许下一次同会话已读上报,消费后立即删除 +- 发送失败不会创建许可 +- 其他会话的已读请求会继续被阻止,也不会消耗当前许可 + +这样可以实现:普通查看保持未读;成功发送普通消息或引用回复后,当前会话此前的消息 +可以正常变为已读。 + +### 多补丁锚点与版本检查 + +- 补丁使用带名称的 `Patch` 结构定义 +- 支持在多个文件中匹配同一补丁锚点 +- 同一文件内按 offset 倒序插入,避免多个补丁产生位置偏移 +- 任一必要锚点缺失时停止修改,避免对不兼容版本生成半成品 ASAR +### ASAR 重打包可靠性 -## 原理 +- 为每个文件重新生成 Electron ASAR 使用的 SHA-256 integrity 元数据 +- 使用 4 MiB block 生成分块哈希 +- 先写入 `messenger.asar.tmp` 并验证可以重新打开 +- 验证通过后再备份原文件并原子替换,降低中途失败损坏客户端的风险 +- 支持通过 `--repatch` 基于已有的原始 `.bak` 重新生成补丁 -飞书客户端前端 js 会监听 DOM 事件并尝试收集新消息的 messageId, 之后传给 -native 层发送网络请求给服务器。所以在传给 native 之前把要传的消息列表 id -清空就好了。 +## 行为预期 -## 已知问题 / TODO +| 场景 | 预期行为 | +| --- | --- | +| 打开会话或滚动浏览 | 不向对方显示已读 | +| 成功发送普通消息 | 当前会话下一次已读上报被放行 | +| 引用消息并成功回复 | 与普通发送相同,当前会话放行一次 | +| 发送失败 | 不放行 | +| 发送后其他会话先产生已读请求 | 其他会话继续被拦截,许可不被消费 | +| 许可超过 1.5 秒 | 自动失效 | -毕竟只是在 js 层修改的逻辑,而非拦截网络请求等更加靠谱的方式。前端可能有多个入口 -来进行消息已读回执的请求,导致在以下情况下对方会看到自己已读: -* 对一个消息进行引用并回复 -* 对一个消息贴表情 -* 接收对方发送的文件 +## 已知限制 -此外要注意,飞书更新后会覆盖修改的文件,所以需要重新执行脚本来修改。 +- 同一会话在 1.5 秒许可窗口内恰好收到新消息时,新消息可能进入本次已读上报 +- 表情回应和接收文件仍可能通过其他前端入口触发已读 +- 在未应用补丁的手机、网页或其他客户端打开消息,仍会正常上报已读 +- 飞书升级后可能更换 bundle 或锚点;必要锚点缺失时脚本会拒绝修改,需要重新适配 diff --git a/asar.py b/asar.py index 484f359..1076827 100644 --- a/asar.py +++ b/asar.py @@ -5,10 +5,14 @@ import os import json import errno +import hashlib import struct import shutil +INTEGRITY_BLOCK_SIZE = 4 * 1024 * 1024 + + def round_up(i, m): """Rounds up ``i`` to the next multiple of ``m``. @@ -114,15 +118,29 @@ def _path_to_dict(path): 'link': os.path.realpath(f.name) } else: - size = f.stat().st_size + with open(f.path, 'rb') as file_pointer: + file_content = file_pointer.read() + + size = len(file_content) + blocks = [ + hashlib.sha256( + file_content[start:start + INTEGRITY_BLOCK_SIZE] + ).hexdigest() + for start in range(0, size, INTEGRITY_BLOCK_SIZE) + ] result['files'][f.name] = { 'size': size, - 'offset': str(offset) + 'offset': str(offset), + 'integrity': { + 'algorithm': 'SHA256', + 'hash': hashlib.sha256(file_content).hexdigest(), + 'blockSize': INTEGRITY_BLOCK_SIZE, + 'blocks': blocks, + }, } - with open(f.path, 'rb') as fp: - concatenated_files += fp.read() + concatenated_files += file_content offset += size diff --git a/main.py b/main.py index 8ceba86..13ab6a2 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,53 @@ """ -使用方法: python main.py <飞书安装根目录> +使用方法: python main.py <飞书安装根目录> [--repatch] """ +import argparse import sys import re import shutil -from typing import Optional, List, Tuple +from typing import Dict, List, NamedTuple, Optional, Pattern, Sequence, Tuple from pathlib import Path from asar import Asar -CODE_PATTERN = re.compile(rb'\w+\.\w+\.info\("updateMessagesMeRead"') -PAYLOAD = b"t.messageIds=[]," +READ_RECEIPT_WINDOW_MS = 1500 + + +class Patch(NamedTuple): + name: str + pattern: Pattern[bytes] + payload: bytes + + +PATCHES = ( + Patch( + name="read-receipt-gate", + pattern=re.compile(rb'\w+\.\w+\.info\("updateMessagesMeRead"'), + payload=( + b"((p=window.__feishuUnreadmePermit,n=Date.now())=>{" + b"if(p&&p.chatId===t.channel?.id&&p.expiresAt>=n&&p.remaining>0){" + b"--p.remaining<=0&&(window.__feishuUnreadmePermit=null)" + b"}else{t.messageIds=[],t.foldIds=[],t.maxPosition=-1," + b"t.maxPositionBadgeCount=0,t.threadId=t.threadMaxPosition=" + b"t.threadMaxPositionBadgeCount=void 0,p&&p.expiresAt List[Tuple[Path, int]]: +def find_file(search_dir: Path, patches: Sequence[Patch] = PATCHES) -> PatchMap: """ - 搜索要修改的 js 文件,返回 (文件路径, 插入位置) 列表 - 插入位置是 x.Y.info("updateMessagesMeRead" 中变量名的起始位置 + 搜索所有补丁锚点,返回 {文件路径: [(插入位置, 补丁), ...]}。 """ all_js_files = list(search_dir.rglob("*.js")) - result = [] + result: PatchMap = {} for js_file in all_js_files: - with open(js_file, 'rb') as f: - try: - content = f.read() - except UnicodeDecodeError: - continue - match = CODE_PATTERN.search(content) - if match: - result.append((js_file, match.start())) + try: + content = js_file.read_bytes() + except OSError: + continue + for patch in patches: + for match in patch.pattern.finditer(content): + result.setdefault(js_file, []).append((match.start(), patch)) return result +def find_missing_patches( + patch_map: PatchMap, patches: Sequence[Patch] = PATCHES +) -> List[Patch]: + matched_names = { + patch.name for anchors in patch_map.values() for _, patch in anchors + } + return [patch for patch in patches if patch.name not in matched_names] + + def make_backup(asar_file: Path): bak_file = asar_file.with_suffix(".asar.bak") if bak_file.exists(): @@ -60,48 +104,74 @@ def make_backup(asar_file: Path): shutil.copy2(asar_file, bak_file) -def modify_file(js_file: Path, offset: int): - print(f"正在修改文件:{js_file}") +def modify_file(js_file: Path, anchors: Sequence[Anchor]): + patch_names = ", ".join(patch.name for _, patch in anchors) + print(f"正在修改文件:{js_file}({patch_names})") with open(js_file, "rb+") as f: content = f.read() - f.seek(offset) - f.write(PAYLOAD) - f.write(content[offset:]) + for offset, patch in sorted(anchors, key=lambda anchor: anchor[0], reverse=True): + content = content[:offset] + patch.payload + content[offset:] + f.seek(0) + f.truncate() + f.write(content) -def main(): - if len(sys.argv) < 2: - print("请指定飞书安装目录", file=sys.stderr) - exit(1) +def parse_args(): + parser = argparse.ArgumentParser(description="修改飞书客户端的已读回执逻辑") + parser.add_argument("install_dir", help="飞书安装根目录") + parser.add_argument( + "--repatch", + action="store_true", + help="使用已有的原始备份重新生成补丁", + ) + return parser.parse_args() + - install_dir = sys.argv[1] +def main(): + args = parse_args() + install_dir = args.install_dir asar_file = find_asar_file(install_dir) if not asar_file: print("未找到 messenger.asar,可能是飞书安装目录指定的不正确,或版本不兼容", file=sys.stderr) exit(1) bak_file = asar_file.with_suffix(".asar.bak") - if bak_file.exists(): + if bak_file.exists() and not args.repatch: print(f"检测到备份文件 {bak_file},似乎已经修改过了。若要重新执行,请先将 `messenger.asar.bak` 重命名回 `messenger.asar`", file=sys.stderr) exit(1) - - make_backup(asar_file) - unpack_asar(asar_file) - - js_files = find_file(UNPACKED_DIR) - if not js_files: - print("未找到需要修改的代码,可能是版本不兼容", file=sys.stderr) - shutil.rmtree(UNPACKED_DIR) + if args.repatch and not bak_file.exists(): + print("未找到原始备份,无法使用 --repatch", file=sys.stderr) exit(1) - for js_file, offset in js_files: - modify_file(js_file, offset) - - # 打包回 asar 文件 - print(f"正在打包:{UNPACKED_DIR} -> {asar_file}") - Asar.pack(UNPACKED_DIR, asar_file) - - # 清理临时目录 - shutil.rmtree(UNPACKED_DIR) + source_asar_file = bak_file if args.repatch else asar_file + if args.repatch: + print(f"使用原始备份重新生成补丁:{source_asar_file}") + + temp_asar_file = asar_file.with_name(f"{asar_file.name}.tmp") + try: + unpack_asar(source_asar_file) + + patch_map = find_file(UNPACKED_DIR) + missing_patches = find_missing_patches(patch_map) + if missing_patches: + missing_names = ", ".join(patch.name for patch in missing_patches) + print(f"未找到补丁锚点:{missing_names},可能是版本不兼容", file=sys.stderr) + exit(1) + + for js_file, anchors in patch_map.items(): + modify_file(js_file, anchors) + + print(f"正在打包:{UNPACKED_DIR} -> {temp_asar_file}") + Asar.pack(UNPACKED_DIR, temp_asar_file) + with Asar.open(temp_asar_file): + pass + + make_backup(asar_file) + temp_asar_file.replace(asar_file) + finally: + if UNPACKED_DIR.exists(): + shutil.rmtree(UNPACKED_DIR) + if temp_asar_file.exists(): + temp_asar_file.unlink() print("修改完成。请重启飞书。若飞书功能异常,请将 `messenger.asar.bak` 重命名回 `messenger.asar`") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_asar.py b/tests/test_asar.py new file mode 100644 index 0000000..bd19217 --- /dev/null +++ b/tests/test_asar.py @@ -0,0 +1,43 @@ +import hashlib +import shutil +import tempfile +import unittest +from pathlib import Path + +from asar import Asar, INTEGRITY_BLOCK_SIZE + + +class AsarIntegrityTests(unittest.TestCase): + def setUp(self): + self.temp_dir = Path(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_pack_writes_integrity_and_round_trips_files(self): + source_dir = self.temp_dir / "source" + source_dir.mkdir() + small_content = b"console.log('hello');" + large_content = b"a" * INTEGRITY_BLOCK_SIZE + b"tail" + (source_dir / "small.js").write_bytes(small_content) + (source_dir / "large.bin").write_bytes(large_content) + archive_path = self.temp_dir / "test.asar" + + Asar.pack(source_dir, archive_path) + + with Asar.open(archive_path) as archive: + small_info = archive.header["files"]["small.js"] + large_info = archive.header["files"]["large.bin"] + self.assertEqual( + small_info["integrity"]["hash"], + hashlib.sha256(small_content).hexdigest(), + ) + self.assertEqual(len(large_info["integrity"]["blocks"]), 2) + archive.extract(self.temp_dir / "extracted") + + self.assertEqual( + (self.temp_dir / "extracted" / "small.js").read_bytes(), small_content + ) + self.assertEqual( + (self.temp_dir / "extracted" / "large.bin").read_bytes(), large_content + ) diff --git a/tests/test_patch.py b/tests/test_patch.py new file mode 100644 index 0000000..e7a784d --- /dev/null +++ b/tests/test_patch.py @@ -0,0 +1,169 @@ +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +import main + + +class PatchDiscoveryTests(unittest.TestCase): + def setUp(self): + self.temp_dir = Path(tempfile.mkdtemp()) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_finds_both_real_anchors(self): + js_file = self.temp_dir / "bundle.js" + js_file.write_bytes( + b'function read(){a.b.info("updateMessagesMeRead",t);}' + b'class Service{send(){c.d.info(' + b'"MessageService::sendMessage:onSendMessageSuccess:",cid);}}' + ) + + patch_map = main.find_file(self.temp_dir) + + self.assertEqual(main.find_missing_patches(patch_map), []) + self.assertEqual( + {patch.name for _, patch in patch_map[js_file]}, + {"read-receipt-gate", "send-success-permit"}, + ) + + def test_reports_a_missing_anchor(self): + (self.temp_dir / "bundle.js").write_bytes( + b'a.b.info("updateMessagesMeRead",t);' + ) + + patch_map = main.find_file(self.temp_dir) + + self.assertEqual( + [patch.name for patch in main.find_missing_patches(patch_map)], + ["send-success-permit"], + ) + + def test_modify_file_applies_multiple_anchors_without_offset_drift(self): + js_file = self.temp_dir / "bundle.js" + content = ( + b'a.b.info("updateMessagesMeRead",t);' + b'c.d.info("MessageService::sendMessage:onSendMessageSuccess:",cid);' + ) + js_file.write_bytes(content) + patch_map = main.find_file(self.temp_dir) + + main.modify_file(js_file, list(reversed(patch_map[js_file]))) + + modified = js_file.read_bytes() + for _, patch in patch_map[js_file]: + self.assertIn(patch.payload, modified) + self.assertTrue(modified.endswith(content[content.index(b"c.d.info"):])) + + +@unittest.skipUnless(shutil.which("node"), "Node.js is required") +class JavaScriptPayloadTests(unittest.TestCase): + def run_javascript(self, body): + result = subprocess.run( + ["node", "-e", body], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + def test_send_success_creates_a_chat_scoped_permit(self): + payload = main.PATCHES[1].payload.decode("ascii") + result = self.run_javascript( + "global.window={}; Date.now=()=>1000;" + "const service={feedId:'chat-a',send(){return (" + f"{payload}0);}}}}; service.send();" + "console.log(JSON.stringify(window.__feishuUnreadmePermit));" + ) + + self.assertEqual( + result, + { + "chatId": "chat-a", + "expiresAt": 1000 + main.READ_RECEIPT_WINDOW_MS, + "remaining": 1, + }, + ) + + def test_matching_chat_preserves_the_complete_report_and_consumes_permit(self): + payload = main.PATCHES[0].payload.decode("ascii") + result = self.run_javascript( + "global.window={__feishuUnreadmePermit:" + "{chatId:'chat-a',expiresAt:2000,remaining:1}};" + "Date.now=()=>1000;let t={channel:{id:'chat-a'},messageIds:['m1']," + "foldIds:['f1'],maxPosition:9,maxPositionBadgeCount:2," + "threadId:'thread-a',threadMaxPosition:8," + "threadMaxPositionBadgeCount:1};" + f"const ignored=({payload}0);" + "console.log(JSON.stringify({t,permit:window.__feishuUnreadmePermit}));" + ) + + self.assertEqual( + result["t"], + { + "channel": {"id": "chat-a"}, + "messageIds": ["m1"], + "foldIds": ["f1"], + "maxPosition": 9, + "maxPositionBadgeCount": 2, + "threadId": "thread-a", + "threadMaxPosition": 8, + "threadMaxPositionBadgeCount": 1, + }, + ) + self.assertIsNone(result["permit"]) + + def test_other_chat_has_every_read_cursor_blocked_without_consuming_permit(self): + payload = main.PATCHES[0].payload.decode("ascii") + result = self.run_javascript( + "global.window={__feishuUnreadmePermit:" + "{chatId:'chat-a',expiresAt:2000,remaining:1}};" + "Date.now=()=>1000;let t={channel:{id:'chat-b'},messageIds:['m1']," + "foldIds:['f1'],maxPosition:9,maxPositionBadgeCount:2," + "threadId:'thread-b',threadMaxPosition:8," + "threadMaxPositionBadgeCount:1};" + f"const ignored=({payload}0);" + "console.log(JSON.stringify({t,permit:window.__feishuUnreadmePermit}));" + ) + + self.assertEqual( + result["t"], + { + "channel": {"id": "chat-b"}, + "messageIds": [], + "foldIds": [], + "maxPosition": -1, + "maxPositionBadgeCount": 0, + }, + ) + self.assertEqual(result["permit"]["chatId"], "chat-a") + self.assertEqual(result["permit"]["remaining"], 1) + + def test_expired_permit_is_blocked_and_removed(self): + payload = main.PATCHES[0].payload.decode("ascii") + result = self.run_javascript( + "global.window={__feishuUnreadmePermit:" + "{chatId:'chat-a',expiresAt:999,remaining:1}};" + "Date.now=()=>1000;let t={channel:{id:'chat-a'},messageIds:['m1']," + "foldIds:['f1'],maxPosition:9,maxPositionBadgeCount:2," + "threadId:'thread-a',threadMaxPosition:8," + "threadMaxPositionBadgeCount:1};" + f"const ignored=({payload}0);" + "console.log(JSON.stringify({t,permit:window.__feishuUnreadmePermit}));" + ) + + self.assertEqual( + result["t"], + { + "channel": {"id": "chat-a"}, + "messageIds": [], + "foldIds": [], + "maxPosition": -1, + "maxPositionBadgeCount": 0, + }, + ) + self.assertIsNone(result["permit"])