Sitelet https://github.com/starccy/feishu-unreadme/pull/9/files
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 81 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
在 js 端屏蔽飞书消息的已读回执。即,自己在看到其他人发送的(私聊或群聊)普通消息后,
对方的视角也会保持在自己未读的状态。

已验证版本:macOS 飞书 7.53.16。


## 使用方法

Expand All @@ -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 或锚点;必要锚点缺失时脚本会拒绝修改,需要重新适配
26 changes: 22 additions & 4 deletions asar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down Expand Up @@ -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

Expand Down
158 changes: 114 additions & 44 deletions main.py
Original file line number Diff line number Diff line change
@@ -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<n&&"
b"(window.__feishuUnreadmePermit=null)}})(),"
),
),
Patch(
name="send-success-permit",
pattern=re.compile(
rb'\w+\.\w+\.info\("MessageService::sendMessage:onSendMessageSuccess:"'
),
payload=(
"window.__feishuUnreadmePermit=this.feedId?{chatId:this.feedId,"
"expiresAt:Date.now()+%d,remaining:1}:null,"
% READ_RECEIPT_WINDOW_MS
).encode("ascii"),
),
)

Anchor = Tuple[int, Patch]
PatchMap = Dict[Path, List[Anchor]]

UNPACKED_DIR = Path(__file__).parent / "unpacked"

Expand All @@ -31,26 +68,33 @@ def unpack_asar(asar_file: Path):
archive.extract(UNPACKED_DIR)


def find_file(search_dir: Path) -> 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():
Expand All @@ -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`")

Expand Down
Empty file added tests/__init__.py
Empty file.
Loading