目标:一名工程师不依赖任何现成脚本,仅凭本文在任何一台 macOS 机器上,对飞书(Lark.app)手工完成防撤回补丁的注入、验证与排障。 撤回机制与加密等深层逆向原理见上一篇《飞书撤回消息逆向分析》;本文只讲怎么做。 实测版本:143.0.7499.203(macOS,Electron/Chromium 143,补丁 v31c)。 131 及更早版本的锚点差异较大(布局都不同),但方法论完全一致,见文末「升级适配」。


0. 前置条件

要求
系统 macOS(Apple Silicon / Intel 均可),已安装飞书 /Applications/Lark.app
工具 nodenpxpython3
npm 包 @electron/asar(npx 自动拉取)、esbuild(语法校验用,npx 自动拉取)
权限 能读写 /Applications/Lark.app/Contents/...(默认可写)

关键事实(先记住,贯穿全文)

  1. 撤回推送会把消息 content 一并抹空(本地 DB 和前端都拿不到原文)——所以必须「渲染时存档、撤回时还原」,只改渲染布尔位只会得到空气泡。
  2. 普通消息撤回的原生样式(居中灰字「你撤回了一条消息」)来自 featureTypeParser 的 RECALLED 分支;行级闸门 return 的提示条组件只服务话题根消息(threadMessageType===1)。
  3. 原生「(已编辑)」标记不是 richText 数据元素,是富文本引擎在渲染尾部按 message.editVersion 标志挂的 suffix 槽位——所以撤回标记要改引擎门槛,而不是往数据里塞元素。
  4. 飞书主进程名是 Feishu(pgrep -x Lark 匹配不到);重启 = pkill -x Feishu + open -a Lark.app
  5. 聊天窗口与设置窗口共用 profile_main 分区的 localStorage(file:// origin)——这是开关跨窗口生效的基础。
  6. Info.plist 里的 AsarIntegrity(SHA256)校验实测不拦截修改后的 asar(改包后正常启动,校验逻辑只记录)。
  7. 开关语义:(默认,frk_hide 不存在)= 显示原文 + 末位「(已撤回)」;(frk_hide=1)= 完全恢复原生行为(parser 保留 isRecalled → 原生 RECALLED 居中灰字提示条,与未打补丁一模一样)。

1. 总流程

备份 asar(3 个) ──► 解包 ──► 定位锚点(特征串 grep) ──► 六类补丁逐文件修改
   ──► 语法校验(node --check + esbuild 双检) ──► 打包 ──► 安装 ──► 重启飞书
   ──► 验证(包内 grep 标记 → 功能测试 → 必要时读 leveldb 诊断)

涉及的三个 asar(相对 webcontent/ 目录;143 起 messenger-next.asar 已取消并入 messenger.asar):

asar 角色
messenger.asar 聊天渲染(实际渲染撤回消息行的是 common/dccbb0fcb3.js,诊断实测)
messenger-modals.asar 弹层视图(parser 副本)
setting.asar 设置窗口(开关行)

2. 步骤一:备份与解包

APP=/Applications/Lark.app
FW="$APP/Contents/Frameworks/Lark Framework.framework/Versions"
WC="$FW/$(ls "$FW" | grep -E '^[0-9]' | sort -V | tail -1)/Resources/webcontent"
# 版本号在路径里,升级 = 整个版本目录替换;用上面的命令自动取最新

mkdir -p ~/feishu_patch/backup
cd ~/feishu_patch
for n in messenger messenger-modals setting; do cp "$WC/$n.asar" "backup/$n.asar.orig"; done

# 解包(注意 setting.asar 根下还有一层 setting/ 目录)
npx --yes @electron/asar extract backup/messenger.asar.orig        msg
npx --yes @electron/asar extract backup/messenger-modals.asar.orig modals
npx --yes @electron/asar extract backup/setting.asar.orig          setting

⚠️ 备份必须在打补丁之前做,且以后只从 .orig 重建;升级飞书后(版本号变化)重新备份。 还原原件 = 把 .orig 拷回去(见 §11)。


3. 步骤二:定位注入点(特征串速查表)

文件名会随版本变化,用特征串搜内容,不要依赖文件名:

补丁 特征串 命中
P1 主窗口闸门 renderRecalledMessage(全库唯一,首选定位) msg 1 个
P1 次要闸门 if(e.message.isRecalled&&!(0, msg 2 个
P2 存档 getByContext({messageItem:e});(注意 let 后变量名 a/r/R/s 各文件不同,泛化匹配) 上述 4 个文件
P3 parser .message.isRecalled&&(0, 且同行有 RECALLED: msg 1 / modals 1
P4 引擎门槛 `!t?.editVersion
P5 文案 "data-disable-copy":!0,children:__Text("eqk") msg 4 个
P6 设置开关 this.renderDuringCalls(),this.renderSound() setting 1 个
P7 生成器存档 976983(e,t,\w){...300 字内...}sv:()=>(定义模块导出 getter) msg 11 + modals 2
P9 推送层存档 Object.values(\w+.messages).forEach(\w+=>{\w+._receiveTime=(cmd 5065 推送处理器) msg 2 + modals 1

143 版本的实际命中文件(19 个;失配时用特征串重找):

P1 主窗口闸门+存档:  msg/29184/cc1bd42c1f.js   (renderRecalledMessage 闸门)
P1 次要闸门+存档:    msg/common/17c8ba9f46.js  msg/common/dccbb0fcb3.js(实际渲染走它)
P3(parser):        msg/34291/b53668c339.js(+存档)  modals/78694/642b69fe24.js
P4(引擎):          msg/6352/217f16060f.js  msg/91894/39cc249a9b.js(同形态 !r||!i)
P5(文案):          msg/77966/c09a31836c.js  msg/common/219990b76b.js
                      msg/common/9cd71f7bd7.js  msg/common/e759ed474c.js
P6(设置):          setting/setting/2eb2987f78.js     ← 注意双层 setting/
P7(生成器存档):    976983 定义 13 份(每个 webpack runtime 一份,必须全打):
                      msg: 49800/911493b952.js, messenger~9/a72666df00.js,
                      topicCreateModal~5/a0f5daf855.js, favorite-detail-viewer/d1f35af664.js,
                      common/{7985b6d948,4fe6202b1b,ed1f4e4a35,f0376c7e4e,78661854b5}.js,
                      2966/6031953774.js, app-chat-window-v2~1/930c57166a.js
                      modals: 82267/eedc721e9e.js, common/cca3a6eab8.js
                      (另有 237673 纯桥: msg/395/3e78968469.js)
P9(推送层存档):    msg/95382/2a17a07f91.js  msg/32293/2e3a217217.js
                      modals/58027/a9f732114a.js

搜索技巧(踩过的坑)

  • webpack 模块定义是对象方法简写 668487(e,t,a){...}——按 668487:(带冒号)永远搜不到定义。
  • 模块 id 每个 bundle 各自独立,跨文件比对要先确认同一 runtime。
  • 文件都是压缩单行,grep 用 grep -o '.\{100\}特征串.\{100\}' 文件 看上下文。
  • i18n key 每个 chunk 重排但主流 chunk 间常统一:先在 messenger/i18n/*-zh-CN.js 值表反查中文文案得 key(如 eqk:e=>"(已编辑)"),再回组件表找使用点。
  • chunk 被 script 加载 ≠ 组件被渲染:排查「补丁没生效」时,可在候选文件最顶部(chunk 加载即执行、不依赖渲染)插 try{localStorage.setItem("frk_load_<tag>","1")}catch(q){} 分层定位。

4. 步骤三:六类补丁逐个修改

以下替换都可以直接写多行代码进压缩文件(JS 合法即可,无需压回单行)。 每个补丁给「锚点(原样)→ 替换后」与一行原理。变量名(FRK_*)刻意全大写防冲突。

P1a 主窗口闸门:撤回时还原存档(1 个文件,143 主路径)

锚点(msg/29184/cc1bd42c1f.js,x=isRecalled、I=isNoTraceDeleted、v=threadMessageType 都是 render 里解构的局部变量;g.m 各文件压缩名不同):

if(x&&!I&&!(0,g.m)(v))return this.renderRecalledMessage();

替换为(结构 if(原条件){闸门体 return this.renderRecalledMessage();}):

if(x&&!I&&!(0,g.m)(v)){
  var FRK_BYP=0, FRK_WHY="";
  var FRK_DBG=function(o){try{var D;try{D=JSON.parse(localStorage.getItem("frk_dbg")||"[]")}catch(q1){D=[]}
    D.push(o);if(D.length>120)D.splice(0,D.length-80);
    localStorage.setItem("frk_dbg",JSON.stringify(D))}catch(q2){}return null};
  /* v31c: 富文本渲染器只画 richText.elements——撤回推送保留 text 掏空 elements,
     还原时用 text 合成元素(形状取自真实消息体),否则数据还原成功但视觉空气泡 */
  var FRK_SY=function(t){return{elementIds:["1"],elements:{"1":{tag:1,style:{},
    property:{text:{content:t,i18nKey:"",numberOfLines:0}},childIds:[],styleKeys:[],
    wideStyle:{},serUpgradeElement:"",lastUpgradeVersion:""}}}};
  try{
    var FRK_M=e.message, FRK_CT=FRK_M.content;
    var FRK_RD=function(c){                    /* 可渲染判定:text 非空 或 elementIds 非空 */
      if(!c)return!1;
      var t=c.get?c.get("text"):c.text;
      if(typeof t=="string"&&t.length)return!0;
      var r=c.get?c.get("richText"):c.richText;
      if(r){var d=r.get?r.get("elementIds"):r.elementIds;
        if(d&&d.size!==undefined)return d.size>0;
        if(Array.isArray(d))return d.length>0}
      return!1};
    var FRK_HIDE=0;
    try{FRK_HIDE=localStorage.getItem("frk_hide")==="1"}catch(q9){}
    if(FRK_HIDE){                              /* 关态=完全原生 */
      if(FRK_M.threadMessageType===1){FRK_WHY="user-hide-root"}   /* 话题根:回落原生提示条 */
      else{FRK_BYP=1;FRK_WHY="user-hide"}      /* 普通消息:放行 → parser 的 RECALLED 接管 */
    }
    else if(FRK_RD(FRK_CT)){FRK_BYP=1;FRK_WHY="ct-ok"}   /* content 未被抹(如重启后DB加载) */
    else{
      var FRK_S=null;
      try{FRK_S=localStorage.getItem("frk_"+FRK_M.id)}catch(FRK_E1){FRK_WHY="ls-err"}
      if(FRK_S){
        var FRK_P=null; try{FRK_P=JSON.parse(FRK_S)}catch(FRK_E2){}
        if(FRK_P&&FRK_RD(FRK_P)){              /* 存档有效性校验:防空壳存档还原成空白 */
          /* 回落档=text 有值/elements 被掏空 → 合成后再整体替换 */
          if(typeof FRK_P.text=="string"&&FRK_P.text.length&&
             !(FRK_P.richText&&FRK_P.richText.elementIds&&FRK_P.richText.elementIds.length))
            FRK_P.richText=FRK_SY(FRK_P.text);
          if(FRK_P.richText&&FRK_CT&&FRK_CT.set){
            var FRK_N=FRK_CT.set("richText",FRK_P.richText);
            if(typeof FRK_P.text=="string"&&FRK_P.text.length)FRK_N=FRK_N.set("text",FRK_P.text);
            if(typeof FRK_P.title=="string"&&FRK_P.title.length)FRK_N=FRK_N.set("title",FRK_P.title);
            this.props.message=e.setIn(["message","content"],FRK_N);
            e=this.props.message;FRK_BYP=1;FRK_WHY="restored";
          }else if(FRK_CT&&FRK_CT.mergeDeep){  /* 兜底:旧版 immutable */
            this.props.message=e.setIn(["message","content"],FRK_CT.mergeDeep(FRK_P));
            e=this.props.message;FRK_BYP=1;FRK_WHY="restored-md";
          }else FRK_WHY="no-set-no-merge";
        }else FRK_WHY="arch-invalid";
      }else{                                   /* 无档 → 5130 digest 兜底(折叠会话) */
        FRK_WHY="no-arch";
        var FRK_FD=null,FRK_DBD=0;
        try{
          var FRK_FH=JSON.parse(localStorage.getItem("frk_fd_"+(FRK_M.chatId||FRK_M.cid))||"[]");
          /* digest 按折叠盒 feedId 入库,与会话 chatId 不同键;直查不中扫全部 frk_fd_* 合并 */
          if(!FRK_FH.length){for(var FRK_KI=0;FRK_KI<localStorage.length;FRK_KI++){
            var FRK_KN=localStorage.key(FRK_KI);
            if(FRK_KN&&FRK_KN.indexOf("frk_fd_")===0){
              try{FRK_FH=FRK_FH.concat(JSON.parse(localStorage.getItem(FRK_KN)||"[]"))}catch(qg){}}}}
          for(var FRK_Q=0;FRK_Q<FRK_FH.length;FRK_Q++){
            var FRK_E2=FRK_FH[FRK_Q];
            if(!FRK_E2||typeof FRK_E2.d!="string"||!FRK_E2.d.length||FRK_E2.d.indexOf("撤回")>=0)continue;
            var FRK_DT=Number(FRK_E2.t)||0, FRK_MT=Number(FRK_M.createTime)||0;
            if(FRK_MT>1e12)FRK_MT=FRK_MT/1e3;  /* digest 秒 vs createTime 毫秒,归一 */
            if(FRK_DT>0&&FRK_MT>0&&Math.abs(FRK_DT-FRK_MT)>900)continue;
            var FRK_DD=Math.abs(FRK_DT-FRK_MT); /* 取时间差最小,防同窗多条张冠李戴 */
            if(!FRK_FD||FRK_DD<FRK_DBD){FRK_FD=FRK_E2;FRK_DBD=FRK_DD}}
          if(FRK_FD&&FRK_CT&&FRK_CT.mergeDeep){
            this.props.message=e.setIn(["message","content"],
              FRK_CT.mergeDeep({text:FRK_FD.d,richText:FRK_SY(FRK_FD.d)}));
            e=this.props.message;FRK_BYP=1;FRK_WHY="fd-arch"}
        }catch(qf){}
      }
    }
  }catch(FRK_X){FRK_BYP=0;FRK_WHY="ex:"+String(FRK_X).slice(0,90)}
  try{FRK_DBG({st:"gate",f:"cc1b",id:(FRK_M&&FRK_M.id)||null,cid:(FRK_M&&(FRK_M.cid||FRK_M.chatId))||null,ch:(FRK_M&&FRK_M.chatId)||null,byp:FRK_BYP,why:FRK_WHY,eEqP:e===this.props.message})}catch(q3){}
  if(!FRK_BYP)return this.renderRecalledMessage();   /* ← 原 return 原样保留 */
}

(f:"cc1b" 是文件标签,各文件不同便于区分来源)

原理:闸门内先判开关——关态放行(由 P3 的 parser 走原生 RECALLED,话题根除外);开态 content 有效直接放行,否则查存档还原。isRecalled 保持 true(标记与开关都靠它);还原写回 this.props.message 并重赋局部 e

P1b 次要闸门:同款逻辑,内联形态(2 个文件)

锚点(msg/common/17c8ba9f46.js 与 dccbb0fcb3.js;只认 if(e.message.isRecalled&&!(0, 开头 + 紧跟的 return...;):

if(e.message.isRecalled&&!(0,X.m)(e.message.threadMessageType))return(0,i.jsx)(l.A,{...this.props});

替换:与 P1a 完全相同的闸门体,包进 if(e.message.isRecalled){...}(条件不含 threadMessageType 判断,由闸门体内部的 threadMessageType===1 分支处理话题根),结尾:

  if(!FRK_BYP)return(0,i.jsx)(l.A,{...this.props});   /* ← 原 return 提示条原样保留 */
}

诊断实测:实际渲染撤回消息行的是 dccbb0fcb3.js(tag dccb);17c8 与 cc1bd42c1f 同装做双保险(不同视图/窗口用不同文件)。

P2 渲染即存档 + 开关快捷键/联动(P1 的 4 个文件 + 34291,共 5 处)

锚点:let X=t.getByContext({messageItem:e});(X 为 a/r/R/s,分号后追加,原样保留)

追加(完整代码,可直接多行):

/* ── 开关注册(每窗口一次):Alt+Shift+R 切换;监听 storage 事件(设置窗口改键后自动刷新)── */
if(!window.__FRK_SW){try{window.__FRK_SW=1;
  document.addEventListener("keydown",function(FRK_EV){
    var FRK_K=FRK_EV.key&&FRK_EV.key.toLowerCase();
    if(!(FRK_EV.altKey&&FRK_EV.shiftKey&&!FRK_EV.ctrlKey&&!FRK_EV.metaKey&&FRK_K==="r"))return;
    var FRK_T=FRK_EV.target,FRK_TG=FRK_T&&FRK_T.tagName;
    if(FRK_TG==="INPUT"||FRK_TG==="TEXTAREA"||(FRK_T&&FRK_T.isContentEditable))return;
    try{if(localStorage.getItem("frk_hide")==="1")localStorage.removeItem("frk_hide");
        else localStorage.setItem("frk_hide","1");
        location.reload()}catch(FRK_E5){}
  },!0);
  window.addEventListener("storage",function(FRK_EV){
    try{if(FRK_EV&&FRK_EV.key==="frk_hide")location.reload()}catch(q7){}
  },!1);
}catch(FRK_E6){}}
/* ── 渲染即存档:真实内容才写,防空值覆盖好存档;window 内存去重防每帧重写;环形索引上限800/淘汰300 ── */
try{
  var FRK_AC=e.message.content;
  var FRK_HA=function(c){                        /* 存档判定:多认 innerText(存档可接受本地回填) */
    if(!c)return!1;
    var t=c.get?c.get("text"):c.text, r=c.get?c.get("richText"):c.richText;
    if(typeof t=="string"&&t.length)return!0;
    if(r){var n=r.get?r.get("innerText"):r.innerText;
      if(typeof n=="string"&&n.length)return!0;
      var d=r.get?r.get("elementIds"):r.elementIds;
      if(d&&d.size!==undefined)return d.size>0;
      if(Array.isArray(d))return d.length>0}
    return!1};
  if(FRK_HA(FRK_AC)){
    var FRK_AJ=FRK_AC.toJS?FRK_AC.toJS():FRK_AC, FRK_AID=e.message.id, FRK_JS=JSON.stringify(FRK_AJ);
    if(FRK_AID&&(!window.__FRK_MEM||window.__FRK_MEM[FRK_AID]!==FRK_JS)){
      localStorage.setItem("frk_"+FRK_AID,FRK_JS);
      window.__FRK_MEM=window.__FRK_MEM||{};window.__FRK_MEM[FRK_AID]=FRK_JS;
      var FRK_AIX;try{FRK_AIX=JSON.parse(localStorage.getItem("frk_idx")||"[]")}catch(FRK_E3){FRK_AIX=[]}
      if(FRK_AIX.indexOf(FRK_AID)<0){FRK_AIX.push(FRK_AID);
        if(FRK_AIX.length>800){var FRK_ADL=FRK_AIX.splice(0,300);
          for(var FRK_AI=0;FRK_AI<FRK_ADL.length;FRK_AI++)localStorage.removeItem("frk_"+FRK_ADL[FRK_AI])}
        localStorage.setItem("frk_idx",JSON.stringify(FRK_AIX))}
    }
  }
}catch(FRK_AX){}

原理:正常渲染路径上每条消息把真实 content 快照进 localStorage(frk_<消息id>); 客户端 id/服务端 id 都会存(同一条消息双档,撤回按服务端 id 命中)。

P3 parser:RECALLED 分支条件化(2 个文件)——开关的核心

锚点:X.message.isRecalled&&(0,Y.m)(Z.message.threadMessageType)?W.RECALLED:

替换:把 isRecalled 换成运行时开关选择属性名(注意 .message 后的点要去掉,点号后不能跟中括号):

X.message[((function(){try{return localStorage.getItem("frk_hide")==="1"}catch(q){return!1}})())?"isRecalled":"isRecallll"]&&(0,Y.m)(Z.message.threadMessageType)?W.RECALLED:

原理:

  • 关态(frk_hide=1):取 "isRecalled"(真值)→ parser 原样判 RECALLED → 撤回消息走原生 RECALLED 组件(居中灰字「你撤回了一条消息」,与未打补丁完全一致)。这是「关 = 默认样式」的实现关键。
  • 开态:取 "isRecallll"(不存在的属性,恒假)→ 撤回消息按原始类型渲染(P1 还原后的内容)。

P4 引擎 suffix 门槛:让撤回消息渲染原生同款标记(2 个文件)

锚点(两个文件同形态,!r||!i 压缩名):

if(!t?.editVersion||!r||!i)return null

替换:

if((!t?.editVersion&&!t?.isRecalled)||!r||!i)return null

原理:renderReeditTag 是引擎渲染「(已编辑)」后缀的开关(段落形态→行内标签挂在最后段落 children 末尾;无段落形态→尾部追加)。加上 isRecalled 后,撤回消息走与原生完全相同的 渲染路径出标记;文案由 P5 换成「(已撤回)」。

P5 已编辑文案组件:撤回时显示「(已撤回)」(4 个文件)

锚点/替换(143 组件签名仍是 ({message:e,timeFormat:t});i18n key 131 版是 dag/dad,143 版是 eqk/eqh——每版要在 zh-CN 值表反查):

"data-disable-copy":!0,children:__Text("eqk")
"data-disable-copy":!0,children:e&&e.isRecalled?"(已撤回)":__Text("eqk")

/* title 同步置空(撤回无 editTimeMs,防悬停出 "Invalid Date 更新") */
{title:__Text("eqh",{Time:
→ {title:(e&&e.isRecalled)?"":__Text("eqh",{Time:

((已撤回) 用全角括号,与原生 eqk=「(已编辑)」一致;每文件 eqk 与 title 各恰好 1 处。 另有 preview__content--reedit 变体(topic 预览等,无 message props)不打——撤回消息不会走到。)

P9 推送层存档:hook cmd 5065 推送处理器(存档主通道)

背景:渲染层存档(P2)只覆盖虚拟列表实际渲染过的消息;生成器存档(P7)只覆盖 已打开会话的可见窗口——推送 handler(receivePushMessagesEntities)把新消息按 position∈[start,end] 过滤后才送生成器,且该 handler 按会话挂载。未打开会话的推送 既不渲染也不生成,等用户打开会话时消息往往已被撤回(isRecalled=true)被存档逻辑跳过 → 无档 → 撤回后回落提示条。根治 = 存档提前到推送入口

锚点(推送服务 K 处理器,cmd 5065||im.v1.PushMessageResponse||PUSH_MESSAGES_V2, 全库仅 3 份副本;变量名按副本自适应,%s 为 forEach 回调的消息变量):

Object.values(n.messages).forEach(t=>{t._receiveTime=e, ...

替换(在回调体内、盖 _receiveTime 之前注入存档;此处 content 已是结构化形态, text/richText/joinToken 与 store Record 同族,与渲染层档格式完全一致):

Object.values(n.messages).forEach(t=>{
  try{
    if(t&&t.isRecalled&&!t.isDeleted){ /* frk_push_r 计数:撤回推送到达即记录 */ }
    if(t&&!t.isRecalled&&!t.isDeleted&&t.id&&t.content){
      var FRK_PJ=t.content.toJS?t.content.toJS():t.content;   /* Immutable/普通对象归一 */
      /* RD 判定(text 非空 ‖ richText.elementIds 非空)通过 →
         localStorage.setItem("frk_"+t.id, JSON.stringify(FRK_PJ))
         + frk_idx 环形 800/300(逻辑与 P2 完全相同,直接搬) */
    }
  }catch(q){}                       /* 全自吞,绝不破坏原 _receiveTime/埋点链 */
  t._receiveTime=e, ...             /* 原语句原样保留 */
})

原理:K 是所有推送消息的全局必经点(cmd 5065 解码后 emit PUSH_MESSAGES_V2 前的 唯一加工点),与哪个会话、是否打开完全无关;此处注册依赖任一会话视图挂载 (initListeners),飞书主窗口常驻会话即可。撤回推送回落语义:撤回推送体常 「保留 text、抹空 elements」,照存但已有档不覆盖(回落档只作无档时的最后手段, 还原侧负责合成元素,见 P1a 的 FRK_SY)。诊断:frk_push_a(存档数)/frk_push_s(空内容跳过)/frk_push_r(撤回推送数)。

P7 生成器存档:hook 消息生成器定义导出(历史拉取的补充通道)

作用:打开会话/滚动加载历史时,批次经 generateMessageItems(同步)/ generateMessageItemsAsyncWithDep(异步)/sv(实例)生成,包装后遍历输出存档—— 覆盖不产生推送的历史消息(进群前/拉旧记录)。

锚点(生成器定义模块的导出 getter;143 版是模块 976983,13 份定义每个 runtime 一份,必须全打, 漏一份该 runtime 的推送/拉取全盲):

976983(e,t,n){...300 字内...}sv:()=>v, NB:()=>X, $F:()=>Y

替换(三 getter 全包装:sv→原型代理(同步方法保持同步返回!),同步/异步→函数包装; 同文件多锚点从导出表尾部往头部替换,先换前面的会撑爆后面锚点的匹配窗口):

sv:()=>{var FRK_o=v;var FRK_p=Object.create(FRK_o);
  FRK_p.generateMessageItems=function(){
    var FRK_R=FRK_o.generateMessageItems.apply(FRK_o,arguments);
    try{ /* 遍历 FRK_R 归一数组: message||item、isRecalled/isDeleted 跳过、
            RD 判定、setItem frk_<id> + frk_idx 环形(同 P2) */ }catch(q){}
    return FRK_R};                      /* 关键:同步方法必须同步返回,包成 Promise 会白屏 */
  FRK_p.generateMessageItemsAsyncWithDep=function(){ /* Promise.resolve(...).then(同上) */ };
  return FRK_p},
NB:()=>{var FRK_g=X;return function(){var FRK_R=FRK_g.apply(this,arguments);
  try{ /* 同上 */ }catch(q){} return FRK_R}},
$F:()=>{var FRK_f=Y;return function(){return Promise.resolve(FRK_f.apply(this,arguments))
  .then(function(FRK_R){try{ /* 同上 */ }catch(q){} return FRK_R})}},

原理:批次 dispatch 进 store 前遍历 messageItems 存档;撤回/删除批次自动跳过, 不覆盖好档。输出归一要兼容 OrderedMap(valueSeq().toArray())/数组/普通对象三形态。 诊断:frk_gm(同步通道)、frk_gm_sv(sv 通道)调用计数。

P10 传输层存档:hook emitJssdkPush 分发回调(折叠会话兜底)

作用:前三层(渲染/推送/生成器)都够不着的消息在此兜住——典型是折叠会话(Box) 的活消息推送被服务端抑制(messageItems 只是壳,不进 K 处理器)。锚点是原生→JS 的 第一站 emitJssdkPush 分发回调,全库 9 副本必须全打(msg 主 chunk + app-chat~1/ thread-preview-window/universal-card-viewer 等 + modals 2;按特征串自动定位)。

注入体职责:

  1. frk_jc_<cmd> 每命令计数——判定「包进没进 JS」;
  2. 载荷 entity.messages 任意形态直接存档(frk_<id>)+ frk_js_<id> 状态史 (见过实体/见过撤回),与 P2/P9 同格式;
  3. frk_shp_<cmd> 首见载荷形状探针;
  4. cmd 5130 feed digest 存档:updatedFeeds.<feedId>.preview.chatData||boxData. localizedDigestMessagefrk_fd_<feedId> 历史队列(每 feed 留 6 条;digest 文本含「撤回」的跳过;时间用 displayTime=秒)。这是折叠会话撤回的唯一内容载体, 渲染闸门 no-arch 时按 P1a 的 fd 兜底消费。

折叠会话链路:撤回推送若携带 entity(text 保留/elements 掏空)→ P9 回落存档接住, 闸门 restored + FRK_SY 合成元素可见;连撤回推送都被掏空 → 闸门 fd 兜底 (扫全部 frk_fd_* 队列,取时间差最小且 ≤900s 的条目,摘要级纯文本)。

P6 设置窗口开关行(1 个文件)

锚点:this.renderDuringCalls(),this.renderSound()(setting/setting/2eb2987f78.js)

替换(两参数之间插一个;含 frk_set 探针,便于排障;143 的 createElement 别名是 i(),Switch 仍是 f.S——压缩别名每版会变,以锚点所在方法实际用的为准):

this.renderDuringCalls(),
(function(){try{
  var FRK_R=(window.__FRK_ROW||(window.__FRK_ROW=function(){
    return i().createElement("div",{className:"notify-during-calls notify-block frk-recall-row"},
      i().createElement(f.S,{
        checked:(function(){try{return localStorage.getItem("frk_hide")!=="1"}catch(q){return!0}})(),
        onCheckChange:function(FRK_V){
          try{if(FRK_V)localStorage.removeItem("frk_hide");
              else localStorage.setItem("frk_hide","1")}catch(q){}
          this.forceUpdate&&this.forceUpdate()
        }.bind(this)},
        i().createElement("p",{className:"notify-title"},"显示撤回的消息内容")))
  })).call(this);
  try{var FRK_N=Date.now();
    if(!window.__FRK_ST||FRK_N-window.__FRK_ST>5000){window.__FRK_ST=FRK_N;
      localStorage.setItem("frk_set",JSON.stringify({ok:1,h:localStorage.getItem("frk_hide")==="1",r:!!FRK_R,t:FRK_N}))}
  }catch(q5){}
  return FRK_R
}catch(FRK_X){try{localStorage.setItem("frk_set","err:"+String(FRK_X).slice(0,140))}catch(q6){}return null}
}).call(this),
this.renderSound()

原理:f.S 是该页原生 Switch 组件(checked+onCheckChange(bool));i()/f 是模块内 现成的 import(锚点所在 renderDuringCalls 用的就是它们),闭包直接复用;window.__FRK_ROW 缓存定义。 设置导航里该页名为 「通知」。语义:checked = frk_hide 不存在(开 = 显示撤回内容,默认)。

⚠️ 嵌套 createElement 的右括号逐层数:label 串后是 p层) f.S层) div层) 函数体} 赋值括号) (window…|| 外括号)`,少一个整体就语法错。


5. 步骤四:语法校验(每改一个文件必做)

node --check <文件>            # 快检
npx --yes esbuild <文件> --outfile=/dev/null --log-level=error   # 全量解析,必须过

node –check 对本项目的 bundle 不可信:这些文件是 ESM(export const __rspack_esm_id=… 开头),node –check 只打一句 “Failed to load the ES module” 警告就退出 0——语法错也静默通过(实测往文件中段塞垃圾都能过)。 esbuild 是唯一可靠判定,两者都跑、以 esbuild 为准。注入体内多分支括号配平错, esbuild 报 Unexpected "catch",用「候选括号矩阵 × esbuild 仲裁」定位最快。


6. 步骤五:打包、安装、重启

npx --yes @electron/asar pack --unpack-dir "*.node" msg    out/messenger.asar
npx --yes @electron/asar pack --unpack-dir "*.node" modals out/messenger-modals.asar
npx --yes @electron/asar pack --unpack-dir "*.node" setting out/setting.asar

for n in messenger messenger-modals setting; do
  install -m 644 "out/$n.asar" "$WC/$n.asar.new"
  mv -f "$WC/$n.asar.new" "$WC/$n.asar"
done

pkill -x Feishu; sleep 3; open -a /Applications/Lark.app   # 补丁下次启动生效

安装命令的输出不要用 grep/tail 过滤——esbuild 失败会中止安装(exit 1),管道会吞掉退出码, 曾因此差点带着旧包确认「装好了」。跑完看 $? + §7① 的包内 grep。


7. 步骤六:验证

① 包内标记(装没装上,以 grep 为准,不要只看脚本输出):

grep -ac 'FRK_V31C' "$WC/messenger.asar"           # ≥3(闸门文件,含 jsarch 传输层)
grep -ac 'FRK_V31C' "$WC/messenger-modals.asar"    # ≥1(modals 副本)
grep -ac 'sv:()=>{var FRK_o=' "$WC/messenger.asar" # ≥1(生成器存档 sv 代理)
grep -ac 'user-hide-root' "$WC/messenger.asar"     # ≥3
grep -ac '"isRecalled":"isRecallll"' "$WC/messenger.asar"          # ≥1(34291 的 parser 开关)
grep -ac 'isRecallll' "$WC/messenger-modals.asar"  # ≥1(modals parser)
grep -ac 'frk-recall-row' "$WC/setting.asar"       # ≥1(开关行)
grep -ac 'fd-arch' "$WC/messenger.asar"            # ≥3(闸门 fd 兜底)

② 功能测试用例:

用例 操作 预期
防撤回(开态) 开关默认开;发一条文本 → 撤回 原文保留 + 文字末位行内灰色「(已撤回)」(与「(已编辑)」同款)
关态=原生 设置 → 通知 → 关「显示撤回的消息内容」 撤回消息全部变成原生居中灰字「你撤回了一条消息」(与未打补丁一模一样);已打开的聊天窗口自动刷新
再开 重新打开开关 撤回消息恢复原文+标记(存档一直在写,随时可切)
无存档兜底 找一条补丁生效前已撤回的旧消息 开态回落原生提示条(不出空气泡);关态原生提示条
快捷键 聊天页 Alt+Shift+R 同开关切换(焦点在输入框时不触发)
推送层存档 对端设备开着任一会话(不必是测试会话);让对方发一条消息到没打开的会话→ 撤回 → 再打开该会话 原文保留 + 「(已撤回)」(cmd 5065 推送到达即存档,与会话是否打开无关);诊断 frk_push_a ≥1、frk_dbg 出现 why:“restored”
折叠会话 把测试会话收进折叠盒;对方发消息 → 撤回 → 展开盒打开会话 原文保留(撤回推送回落档+合成元素,why:“restored”)或摘要级纯文本(digest 兜底,why:“fd-arch”);气泡不再是只有「(已撤回)」的空气泡

8. 排障手册:读 leveldb 诊断

诊断键都写在聊天窗口的 localStorage,落盘于:

~/Library/Application Support/LarkShell/aha/users/<uid>/profile_main/Local Storage/leveldb/

143 注意:业务键大量迁去 IndexedDB(file__0.indexeddb.leveldb),localStorage 写入变稀疏、 compaction 很快(几十 KB 的 .log 几分钟内就并进 .ldb)——扫描要含全部 .log + .ldb (.ldb 是 snappy 压缩块,但键名/frk 字符串常可 latin-1 直读命中)。键可能 ASCII 或 UTF-16LE 双编码。

import glob,re,os
d=os.path.expanduser("~/Library/Application Support/LarkShell/aha/users/<uid>/profile_main/Local Storage/leveldb")
for f in sorted(glob.glob(d+"/*.log")+glob.glob(d+"/*.ldb"), key=os.path.getmtime):
    txt=open(f,'rb').read().decode('latin-1')
    for g in re.findall(r'\{"st":"gate"[^}]{0,150}\}', txt):
        print(os.path.basename(f), g)

why 值含义速查:

why 含义 处置
ct-ok content 未被抹,直接放行 正常
restored / restored-md 存档还原成功 正常(开态最终形态)
user-hide 开关关闭中,普通消息放行走原生 正常(关态最终形态)
user-hide-root 开关关闭中,话题根回落原生提示条 正常(关态话题根)
arch-invalid 存档无效(空壳) 开态回落提示条,设计内
no-arch 该消息无存档(渲染前已撤回/离线撤回) 设计内;发新消息可测
ls-err / ex:... localStorage 异常/闸门异常 看 ex 内容修代码
(frk_dbg 全空) 闸门/存档代码根本没执行 先确认用户打开过会话(存档在任何消息渲染时都写,零写入=渲染路径没走,不一定是补丁问题);再用 §3 的 frk_load 探针分层定位(chunk 加载了没 → 组件渲染了没);最后回 §7① 查包内标记

高频误判:frk_dbg 全是 user-hide ≠ 补丁失效——是 frk_hide=1 残留(旧版本关过开关,升级不清除 localStorage)。切回只能 UI 内操作(聊天窗口 Alt+Shift+R 或 设置→通知→开关);运行中的飞书锁着 leveldb,不能外部改文件切换。

其他诊断键:frk_set(设置页开关行探针,ok:1/err:...)、frk_<消息id>(存档,text 空是富文本,内容在 richText/elements)、frk_idx(索引)、frk_load_<tag>(文件级加载探针)。


9. 已知限制(复现后同样适用)

  • 只还原文本/富文本;图片/文件/语音撤回后资源已失效。
  • thread 回复内撤回、会话列表「最后一条消息」预览、离线期间撤回:未处理。
  • 标记/开关文案硬编码中文(zh-CN)。
  • 旁路视图(pin 预览、搜索)中撤回消息可能渲染为空内容(parser 补丁在开态是全局恒假)。
  • 本地消息用客户端 id,ack 后换服务端 id——双 id 都有存档,不影响。
  • 关态话题根撤回显示原生提示条(user-hide-root),普通消息走 parser RECALLED——两者都是原生样式。

10. 升级适配(锚点失配时)

  1. 重跑流程,先备份新版本 asar;若报「注入失败」(esbuild/锚点计数不对),按报错文件用 §3 特征串重新定位。
  2. 特征串也失配(大重构)时的重定位思路:
    • 主窗口闸门:全局搜 renderRecalledMessage(143 全库唯一,首选);失配再搜 isRecalled,找「消息行组件 render 内 + 解构 isRecalled + return 提示条/方法」的形态(143 把它重构成了解构变量+方法调用,131 是内联 if,先看当前版本哪种);
    • parser:搜 RECALLED(枚举值名一般不变),看三目条件;
    • 引擎:搜 editVersion(标志名来自服务端字段,稳定);确认 renderReeditTag 形态;
    • 文案:在 i18n/*-zh-CN.js 搜中文「(已编辑)」得 key(131 版 dag,143 版 eqk),再反查 __Text("<key>") 使用点;title 的 key 同法搜「 更新」;
    • 设置行:搜「通话与会议中关闭消息通知」相关 render 方法(renderDuringCalls),确认该文件 createElement 的压缩别名(131 是 a(),143 是 i())与 Switch 引用名(f.S)。
  3. 布局变化先确认:asar 所在目录(131 前:Resources/app.asar.unpacked;131:Framework/Versions/<ver>/Resources/webcontent 且有 messenger-next.asar;143:同 webcontent 但 messenger-next 取消)。
  4. 新增渲染树副本时(比如又出一棵 messenger-xxx.asar),全树 grep 特征串,补齐所有副本。
  5. 版本大升级后 THREAD_ROOT_MESSAGE 枚举值(143=1)若变,用 grep -r 'THREAD_ROOT_MESSAGE:' 重新确认,P1 闸门体里 threadMessageType===1 要同步改。

11. 完全还原

for n in messenger messenger-modals setting; do
  cp "backup/$n.asar.orig" "$WC/$n.asar"
done
pkill -x Feishu; sleep 3; open -a /Applications/Lark.app

(核心就是「把 .orig 拷回去 + 重启」,可自行加校验。)


12. 经验教训清单(改码时逐条对照)

  1. 括号逐层数:注入嵌套 createElement/函数,收尾括号少一个,整个文件 syntax error(node –check 可能因懒解析漏报,esbuild 必红)。
  2. bytes 字面量不能含中文(python 脚本注入时);JS 字符串内中文用 \uXXXX 转义或走 str 通道。
  3. React child 表达式要返回 null,不能是 0(0 会被渲染出来)。
  4. 属性名不能用「点+中括号」:.message.[expr] 非法,须 .message[expr](实测,node –check 盲区)。
  5. 改写闸门/还原逻辑时,逐行对照上一版,最容易丢的是「存档有效性校验」这类守卫(丢一次就空壳还原成空白)。
  6. 装完必须 grep 安装包内版本标记再重启;安装输出别用 grep/tail 过滤(管道吞退出码,esbuild 失败会被看漏)。
  7. 主进程名是 Feishu,pgrep -x Lark 匹配不到;重启后主窗口可能不恢复聊天页,要人工打开会话触发渲染。
  8. leveldb 键双编码(ASCII/UTF-16LE),grep 必须两种都查;143 起数据很快 compaction 进 .ldb,扫描含全部 .log+.ldb(latin-1 直读常能命中字符串)。
  9. webpack 模块定义是方法简写(无冒号),模块 id 每 runtime 独立——永远用特征串搜,别用模块 id 跨文件引用
  10. 「补丁没生效」先分层:① 包内 grep 标记(装上没)→ ② frk_load 探针(chunk 加载没)→ ③ frk_idx(组件渲染没,需用户打开会话)→ ④ gate 的 why(逻辑分支对不对)。零写入≠补丁失效。
  11. 回落原生要回到「原生真正的路径」:143 普通消息撤回样式来自 parser RECALLED 分支,不是闸门 return 的提示条组件(那只服务话题根)——回落错组件样式就不对。

13. 知识点清单(本项目用到的全部技术点,按领域)

复现本补丁 = 练习以下知识点。每条标注「在项目中的落点」,可按此反查正文。

13.1 Electron 与桌面应用架构

知识点 落点
Electron 主进程/渲染进程模型、多窗口 §0 飞书 = Electron 壳;聊天窗口与设置窗口是两个渲染进程
asar 归档:拼接式容器 + 索引,可无损解包/重打包 §2 @electron/asar extract/pack;webcontent 下每个功能域一个 asar
asar 完整性校验(Info.plist AsarIntegrity,自研实现):只记录、不拦截修改后的包 §0 关键事实 6;改包后正常启动的实证
session 分区(partition)与 localStorage 隔离:file:// origin + 同 partition ⇒ 共享存储 §4 P6 开关跨窗口生效的前提(都属 profile_main)
storage 事件:同源跨窗口的 localStorage 变更通知 §4 P2 设置窗口改 frk_hide → 聊天窗口自动刷新
app bundle 结构:Contents/Frameworks/<Name>.framework/Versions/<ver>/Resources §2 定位 webcontent 路径;升级 = 版本目录整体替换(路径里带版本号)
hardened runtime 阻止调试器 attach(lldb 不可用) 逆向只能走静态分析 + 行为观察

13.2 打包产物分析(逆向的基本功)

知识点 落点
webpack 产物结构:模块表、__webpack_require__(压缩成 n(...)/r(...))、代码分割 chunk §3 特征串搜代码而不是文件名
模块 id 的 runtime 局部性:不同 bundle 的同名 id 是不同模块 §3 搜索技巧
模块定义的对象方法简写形态 id(e,t,a){...}(无冒号) §3 按带冒号搜永远搜不到定义
压缩单行代码的上下文阅读:grep -o '.\{N\}特征.\{N\}' §3 全文通用技巧
ES 模块导出表 n.d(t,{X:()=>v}) 与 import 别名(s(...)→局部名);压缩别名每版会变(a()→i()) §4 P4/P5/P6 里 f.Si()__Text 的来源
i18n chunk 键重排:值表(中文文案)反查 key,再反查使用点 §3 搜索技巧;§10 重定位

13.3 React 逆向与注入

知识点 落点
class 组件 render 劫持、props 展开传播({...this.props}) §4 P1 还原写 this.props.message + 局部 e 重赋值
生产模式 props 未冻结 ⇒ 可运行时改写(灰色技巧) 同上
createElement 与 jsx 工厂两种产物形态及互改 §4 P1(jsx)vs P6(createElement)
children 序列里的合法表达式:逗号表达式、三元、IIFE;child 必须是 null 不能是 0 §4 P6 开关行;§12.3
注入元素类型无效会炸整棵子树 ⇒ 运行时探针 + try/catch 兜底 §4 P6 frk_set 探针(err 分支)
组件重构形态追踪:同一逻辑 131 是内联 if+jsx,143 是解构变量+方法调用(renderRecalledMessage) §4 P1a vs P1b;§10 升级适配
React 错误边界行为(无边界时渲染异常冒泡卸载) 排障时判断「整页空白 vs 单行缺失」

13.4 数据层:Immutable.js 与消息模型

知识点 落点
Immutable Record/Map:属性访问与 .get() 双通道都要兼容 §4 P1/P2 判定函数里 c.get?c.get("text"):c.text
Record.set() 原样存普通对象 vs mergeDeep 隐式 fromJS 的类型风险 §4 P1 首选 set 整体替换,mergeDeep 仅兜底
toJS() 快照 §4 P2 存档序列化
富文档模型:elementIds/elements/childIds 三层结构、tag 枚举、叶子/段落区分 §1 原理;RD 判定只认 elementIds(innerText 是本地回填不可信)
消息双 id(客户端临时 id → ack 后服务端 id) §9 渲染两阶段都存档,撤回按服务端 id 命中
业务枚举跨版稳定性(THREAD_ROOT_MESSAGE=1、editVersion、isRecalled 字段名) §10 升级适配的锚点选择依据

13.5 飞书业务链路(逆向结论)

知识点 落点
撤回机制:服务端推送 + 本地标记列 + content 抹空 §0 关键事实 1;这是「存档+还原」设计的根因
消息渲染链:行组件 → getByContext → featureTypeParser → 注册表 → ContentComponent §3 P1/P2/P3 的三个环节各拦一道
撤回渲染路径分工:普通消息样式来自 parser RECALLED 分支(居中灰字);行级闸门 return 的提示条只服务话题根 §0 关键事实 2;§4 P3(关态原生化的依据);§12.11
「(已编辑)」 = 引擎 editVersion 标志驱动的 suffix 槽位,非数据元素 §0 关键事实 3;§4 P4
i18n 机制:__Text(key),zh-CN 资源文件可反查文案→键→使用点 §4 P5(143: eqk/eqh);§10 重定位
设置页结构:导航注册表 + 分区渲染;该页实名为「通知」 §4 P6;§10 设置行重定位

13.6 本地存储与取证

知识点 落点
Chromium localStorage 落盘 = LevelDB:.log 明文追加、.ldb snappy 压缩 §8
143 变化:业务键迁 IndexedDB、localStorage 写盘稀疏、compaction 快 → 扫描含全部 .log+.ldb §8;§12.8
LevelDB 键双编码(ASCII / UTF-16LE)都要查 §8;§12.8
用 localStorage 做注入侧持久化(frk_ 存档)+ 诊断通道(frk_dbg/frk_set 环形缓冲)+ 加载探针(frk_load) §4 P1/P2;§3;§8
运行中进程锁定其存储 → 外部改 leveldb 会被内存态覆盖,状态切换只能在应用 UI 内做 §8 高频误判
SQLCipher 加密库与 SHA3-256(Keccak)密钥派生、运行时 secret 静态不可解 详见《飞书撤回消息逆向分析》第三节——这是放弃 DB 直读、改走前端存档的原因

13.7 补丁工程方法论(可迁移到任何 Electron 应用)

知识点 落点
锚点替换法:最小 diff、语义化锚(整段原码)、可逆 §3/§4 全部补丁形态
备份→干净重建→安装(绝不原地二次修改) §2;幂等与版本标记(FRK_V31C)
语法双校验:node –check(V8 懒解析有盲区)+ esbuild 全量 §5;§12.1/§12.4
node 沙箱回放:mock localStorage/Immutable,注入片段先本地跑通再安装 开发期验证(parser 开关两态求值)
诊断探针设计:gate why 枚举、文件级加载探针、5s 节流、环形上限 §3;§4 P1/P6;§8
分层排障法:包内标记 → 文件加载 → 组件渲染 → 逻辑分支,逐层用探针切分 §12.10
对抗式自查:每次改写逐行对照上一版,守卫最容易丢 §12.5
安装验证三步:退出码(不过滤输出)→ 包内 grep 标记 → 重启后功能测试 §6;§7;§12.6

13.8 macOS / shell 杂项

知识点 落点
进程名 ≠ 应用名(Feishu vs Lark):pgrep -x 按精确名匹配 §0;§12.7
重启应用:pkill + open -a;补丁在下次启动生效 §6
版本目录探测(ls Framework/Versions + sort -V)、defaults read Info.plist §2
大文件 grep 需 -a(按文本处理二进制) §7①
无屏幕录制/辅助功能权限时无法 GUI 代操作,验证依赖用户打开会话触发渲染 §12.7/§12.10