sintbot 1.0.15 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. package/src/usage.js +0 -144
@@ -32,7 +32,7 @@ function parseText(message) {
32
32
  let c
33
33
  try { c = JSON.parse(message.content) } catch { return null }
34
34
 
35
- // 附件标记(v0.11.7):图片/文件消息不再整条丢弃 —— 转成带下载坐标的文本标记喂给 agent
35
+ // 附件标记:图片/文件消息不再整条丢弃 —— 转成带下载坐标的文本标记喂给 agent
36
36
  // (真机踩过:「我发给你的图里的那张表」,图被静默丢了,agent 无从知道有图)。标记里
37
37
  // message_id + key 正是 `sbot feishu download` 要的两个参数,清单同步教这条藤怎么摸;
38
38
  // 历史(fetchChatHistory)同一条解析线 ⇒ 群里翻旧图也拿得到坐标。
@@ -145,6 +145,31 @@ function assertCardOk(r, what = '卡片操作') {
145
145
  const ERR_STREAM_DEAD = new Set([200850, 300309])
146
146
  const STREAM_WINDOW_MS = 9 * 60 * 1000 // 10 分钟的窗口留 1 分钟余量(时钟漂移 + 在途请求)
147
147
 
148
+ // Opt-in seam for a future durable effect executor. Every call to invoke is
149
+ // exactly one SDK request; the boundary sees its stable operation ID, target,
150
+ // confirmed result or thrown error. It must persist intent before invoking.
151
+ // Without a boundary, all existing card behavior remains unchanged.
152
+ async function cardOperation(boundary, operationKey, kind, target, invoke) {
153
+ if (!boundary) return invoke()
154
+ if (typeof boundary !== 'function' || typeof operationKey !== 'string' ||
155
+ !operationKey || Buffer.byteLength(operationKey) > 256) {
156
+ throw new TypeError('card operation boundary needs a stable operation key')
157
+ }
158
+ let invoked = false
159
+ let settled = false
160
+ let invokeError = null
161
+ const result = await boundary({ operationId: `${operationKey}:${kind}${target.sequence == null ? '' : ':' + target.sequence}`,
162
+ kind, ...target, invoke: async () => {
163
+ if (invoked) throw new Error('card operation already invoked')
164
+ invoked = true
165
+ try { return await invoke() } catch (e) { invokeError = e; throw e }
166
+ finally { settled = true }
167
+ } })
168
+ if (invokeError) throw invokeError
169
+ if (!invoked || !settled) throw new Error('card operation boundary did not await one SDK call')
170
+ return result
171
+ }
172
+
148
173
  // ── 卡片写入器(纯工厂:client / 时钟 / 窗口全可注入 ⇒ 可单测)─────────────────────
149
174
  // 一张流式卡的**所有**写操作都从这里出去,好处是「往哪条路写」只剩一个判断点:
150
175
  // 窗口内 → cardElement.content(有打字机动画)
@@ -155,6 +180,7 @@ const STREAM_WINDOW_MS = 9 * 60 * 1000 // 10 分钟的窗口留 1 分钟余量(
155
180
  function createCardWriter({
156
181
  client, cardId, elementId = 'answer', log = () => {}, now = () => Date.now(),
157
182
  throttleMs = 0, streamWindowMs = STREAM_WINDOW_MS, setTimer = setTimeout, clearTimer = clearTimeout,
183
+ operationBoundary = null, operationKey = null,
158
184
  } = {}) {
159
185
  const openedAt = now()
160
186
  let seq = 1 // 流式与整卡更新共用的**唯一**序号分配器(混用同一张卡必须同源)
@@ -170,30 +196,64 @@ function createCardWriter({
170
196
  let fulls = 0 // 整卡更新 card.update
171
197
  let closes = 0 // 关流式 card.settings
172
198
  let lastSeen = '' // 最近一次收到的累积文本(判重用)
199
+ let confirmedText = null // 只记录平台确认过的正文,不能把已派发当成已送达
173
200
  let chain = Promise.resolve()
174
- const enqueue = (fn) => { chain = chain.then(fn).catch((e) => log('feishu card op error: ' + (e && e.message))); return chain }
201
+ let finishResult = null
202
+ let boundaryError = null
203
+ let updateRevision = 0
204
+ let flushedRevision = -1
205
+ const enqueue = (fn, { terminal = false } = {}) => {
206
+ const operation = chain.then(() => { if (boundaryError) throw boundaryError; return fn() })
207
+ chain = operation.catch((e) => {
208
+ log('feishu card op error: ' + (e && e.message))
209
+ if (operationBoundary) boundaryError = e
210
+ return null
211
+ })
212
+ return terminal ? (operationBoundary ? operation : operation.then((ack) => ack === true ? true : null, () => null)) : chain
213
+ }
214
+ const confirmedCardAck = (response, action) => {
215
+ assertCardOk(response, action)
216
+ if (!response || response.code !== 0) throw new Error(`${action}未确认:未返回 code=0`)
217
+ return true
218
+ }
175
219
  const expired = () => now() - openedAt >= streamWindowMs
176
220
 
177
221
  // ⚠ 序号由上面那个分配器**统一发**,整卡更新不再自己取秒级时间戳 —— 服务端只比大小
178
222
  // (倒退实测回 300317 sequence number compare failed),小整数完全够用;两条路各自取号
179
223
  // 才是会撞的那种写法。
180
- const writeBody = async (elements) => {
224
+ const writeBody = async (elements, boundary = operationBoundary) => {
181
225
  fulls++
182
- await updateCardEntity(client, cardId, {
183
- schema: '2.0',
184
- config: { streaming_mode: false, summary: { content: '' } },
185
- body: { elements },
186
- }, seq++)
226
+ const sequence = seq++
227
+ await cardOperation(boundary, operationKey, 'card.update',
228
+ { cardId, sequence }, async () => {
229
+ const response = await updateCardEntity(client, cardId, {
230
+ schema: '2.0',
231
+ config: { streaming_mode: false, summary: { content: '' } },
232
+ body: { elements },
233
+ }, sequence)
234
+ confirmedCardAck(response, '更新卡')
235
+ return response
236
+ })
187
237
  streamOpen = false
238
+ return true
239
+ }
240
+ const writeFull = async (text) => {
241
+ const ack = await writeBody([{ tag: 'markdown', content: text, element_id: elementId }])
242
+ confirmedText = text
243
+ return ack
188
244
  }
189
- const writeFull = (text) => writeBody([{ tag: 'markdown', content: text, element_id: elementId }])
190
245
 
191
246
  const pushStream = async (text) => {
192
247
  pushes++
193
- assertCardOk(await client.cardkit.v1.cardElement.content({
248
+ const sequence = seq++
249
+ const uuid = crypto.randomUUID()
250
+ await cardOperation(operationBoundary, operationKey, 'cardElement.content',
251
+ { cardId, elementId, sequence, uuid }, async () => confirmedCardAck(await client.cardkit.v1.cardElement.content({
194
252
  path: { card_id: cardId, element_id: elementId },
195
- data: { content: text, sequence: seq++, uuid: crypto.randomUUID() },
196
- }), '流式推送')
253
+ data: { content: text, sequence, uuid },
254
+ }), '流式推送'))
255
+ confirmedText = text
256
+ return true
197
257
  }
198
258
 
199
259
  // 单一写入口。⚠ **只有「流式到期」那两个码才降级** —— 其它错误照抛(由 enqueue 记进
@@ -207,7 +267,7 @@ function createCardWriter({
207
267
  }
208
268
  if (streamOpen) {
209
269
  try { return await pushStream(text) } catch (e) {
210
- if (!ERR_STREAM_DEAD.has(e.code)) throw e
270
+ if (operationBoundary || !ERR_STREAM_DEAD.has(e.code)) throw e
211
271
  log(`feishu card: 流式已到期(${e.code}),改走整卡更新`)
212
272
  streamOpen = false
213
273
  }
@@ -216,15 +276,19 @@ function createCardWriter({
216
276
  }
217
277
 
218
278
  const closeStreaming = async () => {
219
- if (!streamOpen) return
220
- streamOpen = false
279
+ if (!streamOpen) return true
221
280
  closes++
281
+ const sequence = seq++
282
+ const uuid = crypto.randomUUID()
222
283
  try {
223
- assertCardOk(await client.cardkit.v1.card.settings({
284
+ await cardOperation(operationBoundary, operationKey, 'card.settings',
285
+ { cardId, sequence, uuid }, async () => confirmedCardAck(await client.cardkit.v1.card.settings({
224
286
  path: { card_id: cardId },
225
- data: { settings: JSON.stringify({ config: { streaming_mode: false } }), sequence: seq++, uuid: crypto.randomUUID() },
226
- }), '关流式')
227
- } catch (e) { if (!ERR_STREAM_DEAD.has(e.code)) throw e } // 已经自动关了 = 目的已达成
287
+ data: { settings: JSON.stringify({ config: { streaming_mode: false } }), sequence, uuid },
288
+ }), '关流式'))
289
+ } catch (e) { if (operationBoundary || !ERR_STREAM_DEAD.has(e.code)) throw e } // 已经自动关了 = 目的已达成
290
+ streamOpen = false
291
+ return true
228
292
  }
229
293
 
230
294
  const throttle = createCardThrottle({ intervalMs: throttleMs, send: (t) => enqueue(() => write(t)), now, setTimer, clearTimer })
@@ -236,38 +300,77 @@ function createCardWriter({
236
300
  const t = String(text || '')
237
301
  if (!t || t === lastSeen) return
238
302
  lastSeen = t
303
+ updateRevision++
304
+ flushedRevision = -1
239
305
  throttle.push(t)
240
306
  },
307
+ // Durable v2 callers use this before a terminal reply attempt. Drain a
308
+ // throttled trailing frame and wait for every earlier SDK operation. A
309
+ // concurrent update invalidates the proof, so finish must flush again.
310
+ async flush() {
311
+ if (!operationBoundary || done || frozen) {
312
+ throw new Error('durable card flush needs an open operation boundary')
313
+ }
314
+ const revision = updateRevision
315
+ const trailing = throttle.drain()
316
+ if (trailing !== null) enqueue(() => write(trailing))
317
+ await chain
318
+ if (boundaryError) throw boundaryError
319
+ if (done || frozen || updateRevision !== revision) {
320
+ throw new Error('durable card changed during flush')
321
+ }
322
+ flushedRevision = revision
323
+ return true
324
+ },
241
325
  // buildElements(t):答案含表格时给出整卡元素(表格精致化);返回 null ⇒ 普通 markdown。
242
- finish(text, { buildElements = () => null } = {}) {
243
- if (done) return chain
326
+ finish(text, { buildElements = () => null, terminalOperationBoundary } = {}) {
327
+ if (done) return finishResult
328
+ const terminal = terminalOperationBoundary !== undefined
329
+ if (terminal && (!operationBoundary || typeof terminalOperationBoundary !== 'function' ||
330
+ flushedRevision !== updateRevision || frozen ||
331
+ typeof text !== 'string' || !text.trim())) {
332
+ throw new Error('durable card terminal needs flushed nonempty text and one boundary')
333
+ }
244
334
  done = true
245
335
  const leftover = throttle.drain() // 取消挂起帧,拿回还没推的文本
246
336
  const t = String(text || '') || leftover || ''
247
337
  let els = null
248
338
  try { els = buildElements(t) } catch { /* 构建失败按无表格处理 */ }
249
- const needsWrite = !!(t && t !== throttle.lastSent())
250
- return enqueue(async () => {
339
+ finishResult = enqueue(async () => {
251
340
  try {
341
+ if (terminal) {
342
+ // The outer finalReply effect owns this one SDK call. A final
343
+ // card.update also closes streaming mode; no card.settings or
344
+ // second outbound attempt may be nested under that effect.
345
+ const elements = els || [{ tag: 'markdown', content: t, element_id: elementId }]
346
+ const ack = await writeBody(elements, terminalOperationBoundary)
347
+ confirmedText = t
348
+ return ack
349
+ }
252
350
  if (els) {
253
351
  // 表格被拒就退普通 markdown(宁可松散不可丢内容)
254
- try { return await writeBody(els) } catch (e) { log('feishu card table render fallback: ' + (e && e.message)) }
352
+ try { const ack = await writeBody(els); confirmedText = t; return ack }
353
+ catch (e) {
354
+ if (operationBoundary) throw e
355
+ log('feishu card table render fallback: ' + (e && e.message))
356
+ }
255
357
  }
256
358
  // ⭐ 有正文一律整卡更新落地;没有新正文才只关流式 —— **绝不拿空正文去刷卡**,
257
359
  // 那会把已经推上去的内容抹掉(「ack 后转静默」走的正是空正文这条)。
258
- if (needsWrite) return await writeFull(t)
259
- await closeStreaming()
360
+ if (t && t !== confirmedText) return await writeFull(t)
361
+ return await closeStreaming()
260
362
  } finally {
261
363
  // ⚠ 这行必须打在**收尾之后**:收尾自己那一次调用(整卡更新 / 关流式)就发生在这个
262
364
  // 回调里。原先它打在回调外、且只报节流器的派发次数 ⇒ 降级后的整卡更新与收尾那次
263
365
  // 全在账外(实测漏计 ≈44 次/天)。注释写着「配额账可实测」,那就得真能对上账。
264
366
  log(`card ops: ${pushes + fulls + closes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes})`)
265
367
  }
266
- })
368
+ }, { terminal: true })
369
+ return finishResult
267
370
  },
268
371
  // recall:先断掉后续流式帧再去删消息。⚠ 只冻结不收尾 —— 撤不掉时调用方还要退一句中性收尾。
269
- markClosed() { frozen = true },
270
- settle: () => chain,
372
+ markClosed() { frozen = true; flushedRevision = -1 },
373
+ settle: () => chain.then((result) => { if (boundaryError) throw boundaryError; return result }),
271
374
  stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes, streamOpen, seq }),
272
375
  }
273
376
  }
@@ -285,7 +388,7 @@ function createCardWriter({
285
388
  * card — 流式卡片调优:throttleMs(推送节流窗,默认 1500;0 = 逐帧推)
286
389
  * @returns {import('../core/interfaces').Platform}
287
390
  */
288
- // ── 回答卡表格精致化(v0.11.5)──────────────────────────────────────────────
391
+ // ── 回答卡表格精致化──────────────────────────────────────────────
289
392
  // markdown 表格在卡片里被客户端渲染成分页控件:行高松散、长列截断还要横向滚动
290
393
  // (2026-08-04 群里真实反馈「显示不全、不紧凑、不精致」)。原生 table 组件能钉住这些:
291
394
  // 紧凑行高(low)+ 固定列宽 + 灰底表头。列宽策略出自真机探针逐张对比、operator 拍板:
@@ -344,7 +447,7 @@ function tableElement(seg) {
344
447
  }
345
448
  }
346
449
 
347
- // 可复制版折叠区(v0.11.11):原生 table 好看但手机端选不中、整卡转发也带不走表格内容(飞书平台
450
+ // 可复制版折叠区:原生 table 好看但手机端选不中、整卡转发也带不走表格内容(飞书平台
348
451
  // 限制,卡片 config 无「可复制」开关)。v0.11.4 的解法是每次都**另发一条**纯文本 —— 真机 14 小时
349
452
  // 里 4 次,其中 2 次卡片内已有同样的表格,纯属重复,群里嫌累赘(2026-08-06 用户提)。
350
453
  // 现在收进卡片内一个**默认收起**的折叠面板:不要的人完全看不见,要复制的人展开即得,群里不再
@@ -370,7 +473,7 @@ function copyPanel(tsv) {
370
473
  }
371
474
  }
372
475
 
373
- // 「过程」折叠区(v0.11.93):引擎从本轮正文里认出的里程碑行(`>> …`,见 engine milestonesOf),收尾
476
+ // 「过程」折叠区:引擎从本轮正文里认出的里程碑行(`>> …`,见 engine milestonesOf),收尾
374
477
  // 时整批交到这里。默认收起 —— 卡片仍然只露答案(v0.11.11 的「简」不破),要看做了哪几步的人展开即得;
375
478
  // 中止 / 出错时它就是「做到哪了」。形状与 copyPanel 同款(同一组已被服务端接受的字段)。
376
479
  function milestonePanel(lines) {
@@ -396,13 +499,14 @@ function milestonePanel(lines) {
396
499
  // 三样都没有 ⇒ null(调用方走原 markdown 收尾,零变化)。
397
500
  // footer(2026-09-16 用户提):耗时与 tokens 一行,引擎拼好文字、这里只管形状 —— 与生图进度卡 /
398
501
  // 接管卡的小字同款(text_size notation + 灰字;note 组件在 schema 2.0 已被拒,见 memory 卡片全集)。
399
- function answerCardElements(text, { milestones = [], footer = '' } = {}) {
502
+ function answerCardElements(text, { milestones = [], footer = '', footerDetail = '' } = {}) {
400
503
  const segs = scanTables(text)
401
504
  const tables = segs.filter((s) => s.kind === 'table')
402
505
  const ms = (Array.isArray(milestones) ? milestones : []).filter((m) => typeof m === 'string' && m.trim())
403
506
  const ft = typeof footer === 'string' ? footer.trim() : ''
507
+ const ftDetail = typeof footerDetail === 'string' ? footerDetail.trim() : ''
404
508
  const nativeTables = tables.length > 0 && tables.length <= 5
405
- if (!nativeTables && !ms.length && !ft) return null
509
+ if (!nativeTables && !ms.length && !ft && !ftDetail) return null
406
510
  const elements = []
407
511
  if (nativeTables) {
408
512
  for (const seg of segs) {
@@ -424,18 +528,24 @@ function answerCardElements(text, { milestones = [], footer = '' } = {}) {
424
528
  // ⚠ 顺序钉死:正文 → 可复制版 → 过程 → 小字。小字是整卡最底、右对齐(2026-09-16 定下的位置:
425
529
  // 「小号字体、卡片右下角」);notation = 12px,是 markdown 组件最小的一档,右对齐靠 text_align。
426
530
  if (ft) elements.push({ tag: 'markdown', text_size: 'notation', text_align: 'right', content: `<font color='grey'>${ft}</font>` })
531
+ // 型号来自可配置字段,不能直接插进 font 标签;把第二行独立成元素,手机宽度不足时自然换行。
532
+ if (ftDetail) {
533
+ const safe = ftDetail.replace(/[\r\n]+/g, ' ').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
534
+ elements.push({ tag: 'markdown', text_size: 'notation', text_align: 'right', content: `<font color='grey'>${safe}</font>` })
535
+ }
427
536
  return elements
428
537
  }
429
538
 
430
- function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMentioned = false, log = () => {}, onConn = () => {}, ws = {}, card = {} } = {}) {
539
+ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMentioned = false, log = () => {}, onConn = () => {}, ws = {}, card = {}, client: suppliedClient = null } = {}) {
431
540
  if (!appId || !appSecret) throw new Error('feishu: appId and appSecret required')
432
541
 
433
- const client = new lark.Client({ appId, appSecret, loggerLevel: lark.LoggerLevel.warn })
542
+ const client = suppliedClient || new lark.Client({ appId, appSecret, loggerLevel: lark.LoggerLevel.warn })
434
543
  // 流式卡片推送节流窗(ms)。缺省 1500 = 每 1.5s 最多刷一次卡;显式 0 ⇒ 逐帧推(旧行为)。
435
544
  const cardThrottleMs = Number(card.throttleMs) === 0 ? 0 : Math.max(0, Number(card.throttleMs) || 1500)
436
545
  let wsClient = null
437
546
  let watchdog = null
438
547
  let tickTimer = null
548
+ let serviceIntakeClosed = true
439
549
 
440
550
  // WS liveness tuning — see wswatchdog.js. All configurable via cfg.feishu.ws so the
441
551
  // detection策略 can be tuned on the real machine without a code change. pingTimeout is the KEY knob: the SDK's pong-watchdog is off by
@@ -482,9 +592,12 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
482
592
  params: { receive_id_type: 'chat_id' },
483
593
  data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: content }) },
484
594
  })
485
- // 返回 message_id:画布播报靠它关联「点表情即触发」(boardwatch.takeNotice);
486
- // 其余调用方历来不看返回值,零回归。
487
- return (r && r.data && r.data.message_id) || (r && r.message_id) || null
595
+ // SDK 对 HTTP 200 + 业务 code≠0 不抛。只有 code===0 且有消息 ID
596
+ // 才能把主动发送当作已确认;调用方据此决定是否结算。
597
+ if (!r || r.code !== 0) { log('feishu send rejected: code=' + (r && r.code)); return null }
598
+ const messageId = (r.data && r.data.message_id) || r.message_id || null
599
+ if (!messageId) log('feishu send rejected: missing message_id')
600
+ return messageId
488
601
  } catch (e) {
489
602
  log('feishu send error: ' + larkErrMsg(e))
490
603
  return null
@@ -492,8 +605,34 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
492
605
  }
493
606
 
494
607
  return {
608
+ features: { cardQuota: true, noticeCard: true }, // 飞书支持通知卡;企微/Slack 退回文本
495
609
  async start(onMessage, hooks = {}) {
496
610
  if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
611
+ if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {
612
+ throw new TypeError('feishu fatal intake hook must be a function')
613
+ }
614
+ serviceIntakeClosed = false
615
+ let intakeClosed = false
616
+ const closeFatalIntake = (error) => {
617
+ if (!hooks.onFatal || intakeClosed) return
618
+ // This is the opt-in durable ingress boundary: a failed receipt/lock
619
+ // operation cannot be reduced to a log line and followed by more input.
620
+ intakeClosed = true
621
+ serviceIntakeClosed = true
622
+ if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
623
+ if (watchdog) watchdog.stop()
624
+ try { if (wsClient?.close) wsClient.close({ force: true }) } catch {}
625
+ try {
626
+ Promise.resolve(hooks.onFatal(error)).catch(e =>
627
+ log('feishu fatal intake hook rejected: ' + (e && e.message)))
628
+ } catch (e) {
629
+ log('feishu fatal intake hook threw: ' + (e && e.message))
630
+ }
631
+ }
632
+ const reportOnMessageFailure = error => {
633
+ log('feishu onMessage error: ' + (error && error.message))
634
+ closeFatalIntake(error)
635
+ }
497
636
 
498
637
  const dispatcher = new lark.EventDispatcher({}).register({
499
638
  // 撤回即停:用户撤回了发给 bot 的消息 ⇒ 引擎跳过/中止对它的处理(排队剔除,运行中如 /stop)。
@@ -502,6 +641,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
502
641
  // no-handle 警告实锤在推),注册 handler 即收,不需要发版。⚠ payload 不带 chat_id —— 匹配
503
642
  // 只靠 message_id(播报消息 id 全局唯一,足够)。
504
643
  'im.message.reaction.created_v1': async (data) => {
644
+ if (serviceIntakeClosed) return
505
645
  if (watchdog) watchdog.onEvent()
506
646
  try {
507
647
  const ev = (data && data.event) || data || {}
@@ -532,6 +672,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
532
672
  },
533
673
  // 需在开放平台补订阅 im.message.recalled_v1(与 receive_v1 同权限);未订阅则事件不来,零回归。
534
674
  'im.message.recalled_v1': async (data) => {
675
+ if (serviceIntakeClosed) return
535
676
  if (watchdog) watchdog.onEvent() // 任何入站事件都是链路活性信号
536
677
  try {
537
678
  const ev = (data && data.event) || data || {}
@@ -551,6 +692,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
551
692
  // (subscribeBoard),后台还须订阅事件类型 + 发版;未订阅零回归。纪律同 recall:到达即
552
693
  // 打日志、不在 handler 里 await 慢查询。
553
694
  'board.whiteboard.updated_v1': async (data) => {
695
+ if (serviceIntakeClosed) return
554
696
  if (watchdog) watchdog.onEvent()
555
697
  try {
556
698
  const ev = (data && data.event) || data || {}
@@ -567,6 +709,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
567
709
  }
568
710
  },
569
711
  'im.message.receive_v1': async (data) => {
712
+ if (intakeClosed || serviceIntakeClosed) return
570
713
  if (watchdog) watchdog.onEvent() // any inbound event ⇒ link is alive (liveness signal)
571
714
  try {
572
715
  const ev = (data && data.event) || data || {}
@@ -597,7 +740,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
597
740
  // Fire-and-forget into the engine (NFR1): a turn can run for minutes,
598
741
  // and whether the SDK serializes pending handlers is not contractual —
599
742
  // message reception must never block on turn completion.
600
- Promise.resolve(onMessage({
743
+ let result
744
+ try { result = onMessage({
601
745
  chatId: message.chat_id,
602
746
  userId: openId || 'unknown',
603
747
  senderName,
@@ -605,9 +749,11 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
605
749
  msgId: message.message_id,
606
750
  createTimeMs: Number(message.create_time) || Date.now(),
607
751
  replyCtx: { chatId: message.chat_id, messageId: message.message_id },
608
- })).catch((e) => log('feishu onMessage error: ' + (e && e.message)))
752
+ }) } catch (error) { reportOnMessageFailure(error); return }
753
+ Promise.resolve(result).catch(reportOnMessageFailure)
609
754
  } catch (e) {
610
755
  log('feishu event error: ' + (e && e.message))
756
+ closeFatalIntake(e)
611
757
  }
612
758
  },
613
759
  })
@@ -669,7 +815,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
669
815
 
670
816
  async reply(replyCtx, content) {
671
817
  const mid = replyCtx && replyCtx.messageId
672
- // 挂到触发它的那条消息下(v0.11.10):群里多人交叉说话时,独立发一条看不出这句在回应谁。
818
+ // 挂到触发它的那条消息下:群里多人交叉说话时,独立发一条看不出这句在回应谁。
673
819
  // 回复失败(原消息已撤回/超期)⇒ 退回直接发 —— 形态是锦上添花,把消息弄丢才是事故。
674
820
  if (mid && content) {
675
821
  try {
@@ -682,6 +828,20 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
682
828
  return sendText(replyCtx && replyCtx.chatId, content)
683
829
  },
684
830
 
831
+ // Durable terminal effects already record one attempt before this call.
832
+ // Return the raw SDK outcome so the caller can require a message ID;
833
+ // never fall back to a second send when this attempt is ambiguous.
834
+ async replyOnce(replyCtx, content) {
835
+ const mid = replyCtx && replyCtx.messageId
836
+ if (typeof mid !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(mid) ||
837
+ typeof replyCtx.chatId !== 'string' || !replyCtx.chatId ||
838
+ typeof content !== 'string' || !content) {
839
+ throw new TypeError('durable Feishu reply needs exact message target and text')
840
+ }
841
+ return client.im.message.reply({ path: { message_id: mid },
842
+ data: { msg_type: 'text', content: JSON.stringify({ text: content }) } })
843
+ },
844
+
685
845
  async send(chatId, content) {
686
846
  return sendText(chatId, content)
687
847
  },
@@ -697,9 +857,15 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
697
857
  // old content is always a prefix of the new, the client renders a typewriter
698
858
  // effect. `finish` flips streaming_mode off so the card settles.
699
859
  // All ops are serialized so the per-card `sequence` stays strictly increasing.
700
- async createStreamingCard(replyCtx, { title = 'sbot' } = {}) {
860
+ async createStreamingCard(replyCtx, { title = 'sbot', operationBoundary = null,
861
+ operationKey = null } = {}) {
701
862
  const chatId = replyCtx && replyCtx.chatId
702
863
  if (!chatId) return null
864
+ if (operationBoundary && (typeof operationBoundary !== 'function' ||
865
+ typeof operationKey !== 'string' || !operationKey ||
866
+ Buffer.byteLength(operationKey) > 256)) {
867
+ throw new TypeError('card operation boundary needs a stable operation key')
868
+ }
703
869
  const ELEM = 'answer'
704
870
  // No header — the reply card shows just the answer body (no "sbot" title line).
705
871
  const cardJson = {
@@ -709,54 +875,120 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
709
875
  }
710
876
 
711
877
  let cardId
712
- let msgId = null // 撤回这张卡要用(ack 后转静默的收尾);投递结果里带回来,别丢在地上
878
+ let msgId = null // 静默收尾时撤回这张卡要用;投递结果里带回来,别丢在地上
713
879
  try {
714
880
  // ⚠ SDK 对业务错误(HTTP 200 + code≠0)不抛 —— 不查 code 的话,建卡被拒时这里拿到的是
715
881
  // 空 card_id,拿着它去投递会 400,**真实的 cardkit 拒绝理由就被那个 400 盖掉了**
716
882
  // (排查时只看得见「消息发送失败」)。sendChatCard 早修过同一处,写法照抄。
717
- const created = assertCardOk(await client.cardkit.v1.card.create({ data: { type: 'card_json', data: JSON.stringify(cardJson) } }), '建卡')
883
+ const created = await cardOperation(operationBoundary, operationKey, 'card.create',
884
+ { chatId, replyToMsgId: replyCtx && replyCtx.messageId || null },
885
+ async () => {
886
+ const response = assertCardOk(await client.cardkit.v1.card.create({
887
+ data: { type: 'card_json', data: JSON.stringify(cardJson) },
888
+ }), '建卡')
889
+ if (operationBoundary && response?.code !== 0) {
890
+ throw new Error('建卡结果未确认:未返回 code=0')
891
+ }
892
+ if (!response?.data?.card_id) throw new Error('建卡被拒:未返回 card_id')
893
+ return response
894
+ })
718
895
  cardId = created && created.data && created.data.card_id
719
896
  if (!cardId) throw new Error('建卡被拒:未返回 card_id')
720
- // 挂到触发它的那条消息下(v0.11.10):群里多人交叉说话时,独立发一条卡看不出在回应谁。
897
+ // 挂到触发它的那条消息下:群里多人交叉说话时,独立发一条卡看不出在回应谁。
721
898
  // 排队合并批次的锚点是最新那条(见 _coalesce)。回复形态的卡照常流式更新(真机探针验过)。
722
899
  // 拿不到消息 id(合成消息)或回复失败(原消息已被撤回)⇒ 退回直接发,绝不因此丢答案。
723
- const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId)
900
+ const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId,
901
+ { operationBoundary, operationKey })
724
902
  msgId = posted
725
903
  } catch (e) {
726
904
  log('feishu card create error: ' + (e && e.message))
905
+ if (operationBoundary) throw e
727
906
  return null // caller falls back to a plain-text reply
728
907
  }
729
908
 
730
909
  // 全部写操作交给写入器(流式窗口判断、序号分配、错误码检查都在它一处)。
731
- const writer = createCardWriter({ client, cardId, elementId: ELEM, log, throttleMs: cardThrottleMs })
910
+ const writer = createCardWriter({ client, cardId, elementId: ELEM, log,
911
+ throttleMs: cardThrottleMs, operationBoundary, operationKey })
732
912
 
733
913
  return {
734
- // 撤回整张卡(ack 之后该轮却以静默/中止收场时用)。scope 零新增 —— send_as_bot 本就
914
+ durableTarget() {
915
+ if (!operationBoundary || !cardId || !msgId) {
916
+ throw new Error('durable card target unavailable')
917
+ }
918
+ return Object.freeze({ chatId, cardId, msgId })
919
+ },
920
+ // 撤回整张卡(ack 或过程文字之后该轮却以静默收场时用)。scope 零新增 —— send_as_bot 本就
735
921
  // 覆盖撤回自己发的消息。先把在途的卡片操作排干,免得撤完又被补一帧打回来。
736
922
  async recall() {
737
923
  if (!msgId) return false
738
924
  writer.markClosed()
739
- try { await writer.settle() } catch { /* 在途操作失败无所谓,照撤 */ }
740
- try { await client.im.message.delete({ path: { message_id: msgId } }); return true }
741
- catch (e) { log('feishu card recall error: ' + (e && e.message)); return false }
925
+ try { await writer.settle() } catch (e) {
926
+ if (operationBoundary) throw e
927
+ /* 在途操作失败无所谓,照撤 */
928
+ }
929
+ try {
930
+ await cardOperation(operationBoundary, operationKey, 'message.delete',
931
+ { chatId, cardId, msgId }, async () => {
932
+ const response = assertCardOk(await client.im.message.delete({
933
+ path: { message_id: msgId },
934
+ }), '撤卡')
935
+ if (operationBoundary && response?.code !== 0) {
936
+ throw new Error('撤卡结果未确认:未返回 code=0')
937
+ }
938
+ return response
939
+ })
940
+ return true
941
+ } catch (e) {
942
+ log('feishu card recall error: ' + (e && e.message))
943
+ if (operationBoundary) throw e
944
+ return false
945
+ }
742
946
  },
743
947
  // push the FULL text so far (cumulative). No-ops if unchanged (hash-skip).
744
948
  update(text) { writer.update(text) },
745
- // 收尾:把最终正文落地并关掉流式。表格精致化(v0.11.5)= 答案含表格时整卡更新成
949
+ // Opt-in durable terminal handoff: all earlier card operations must
950
+ // finish before the terminal effect begins its own external attempt.
951
+ flush() { return writer.flush() },
952
+ // 收尾:把最终正文落地并关掉流式。表格精致化= 答案含表格时整卡更新成
746
953
  // 「markdown 段 + 原生表格组件」;无表格也走整卡更新(流式那条路会在第 10 分钟失效)。
747
- // opts.milestones:引擎认出的 `>> ` 里程碑,收进「过程」折叠区(v0.11.93)
954
+ // opts.milestones:引擎认出的 `>> ` 里程碑,收进「过程」折叠区
748
955
  // opts.footer:耗时与 tokens 一行小字,挂在整卡最底(2026-09-16)
749
- finish(text, opts = {}) { return writer.finish(text, { buildElements: (t) => answerCardElements(t, opts) }) },
956
+ finish(text, opts = {}) { return writer.finish(text, {
957
+ buildElements: (t) => answerCardElements(t, opts),
958
+ ...(Object.hasOwn(opts, 'terminalOperationBoundary')
959
+ ? { terminalOperationBoundary: opts.terminalOperationBoundary } : {}),
960
+ }) },
750
961
  }
751
962
  },
752
963
 
753
- async stop() {
964
+ async stop({ strict = false } = {}) {
754
965
  // Stop the watchdog + tick first so a mid-shutdown state change can't trigger
755
966
  // a rebuild, then close the socket cleanly (close({force}) is the SDK's
756
967
  // documented teardown — kills timers + the reconnect loop, no orphan sockets).
968
+ if (typeof strict !== 'boolean') throw new TypeError('feishu stop strict must be boolean')
969
+ serviceIntakeClosed = true
757
970
  if (tickTimer) { try { clearInterval(tickTimer) } catch {} tickTimer = null }
758
- if (watchdog) { try { watchdog.stop() } catch {} }
759
- try { if (wsClient && wsClient.close) wsClient.close({ force: true }) } catch {}
971
+ if (watchdog) {
972
+ if (strict) watchdog.stop()
973
+ else try { watchdog.stop() } catch {}
974
+ }
975
+ const closing = wsClient
976
+ if (strict) {
977
+ if (closing) {
978
+ if (typeof closing.close !== 'function' ||
979
+ typeof closing.getConnectionStatus !== 'function') {
980
+ throw new Error('feishu strict stop cannot verify WS closure')
981
+ }
982
+ await closing.close({ force: true })
983
+ const state = closing.getConnectionStatus()?.state
984
+ if (state !== 'idle') {
985
+ throw new Error('feishu strict stop WS remains connected or unverified')
986
+ }
987
+ }
988
+ wsClient = null
989
+ return true
990
+ }
991
+ try { if (closing?.close) await closing.close({ force: true }) } catch {}
760
992
  wsClient = null
761
993
  },
762
994
  }
@@ -1215,21 +1447,46 @@ async function sendChatImage(client, chatId, absPath) {
1215
1447
  // msgId 供按钮回调/表情命中(takeNotice 锚点),cardId 供后续实体全量更新(单卡回填)。
1216
1448
  // 投递一张已建好的卡:优先「回复」到指定消息(群里看得出在回应谁),没有消息 id 或回复被拒
1217
1449
  // (原消息已撤回/超出可回复期)就退回直接发到群里。返回 message_id(撤回/表情命中锚点)。
1218
- async function postCard(client, chatId, replyToMsgId, cardId) {
1450
+ async function postCard(client, chatId, replyToMsgId, cardId,
1451
+ { operationBoundary = null, operationKey = null } = {}) {
1219
1452
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } })
1220
1453
  const pick = (r) => (r && ((r.data && r.data.message_id) || r.message_id)) || null
1221
1454
  if (replyToMsgId) {
1222
1455
  try {
1223
1456
  // ⚠ SDK 对业务错误不抛(HTTP 200 + code≠0)⇒ 判成功要看 code,不能只看有没有 id:
1224
1457
  // 成功却没取到 id 时若落到下面的直发,群里就是**两张卡**(比失去撤回能力糟得多)。
1225
- const r = await client.im.message.reply({ path: { message_id: replyToMsgId }, data: { msg_type: 'interactive', content } })
1458
+ const r = await cardOperation(operationBoundary, operationKey, 'message.reply',
1459
+ { chatId, cardId, replyToMsgId }, async () => {
1460
+ const response = await client.im.message.reply({ path: { message_id: replyToMsgId },
1461
+ data: { msg_type: 'interactive', content } })
1462
+ if (operationBoundary && typeof response?.code !== 'number') {
1463
+ throw new Error('卡片回复结果未确认:未返回业务码')
1464
+ }
1465
+ if (operationBoundary && response?.code === 0 && !pick(response)) {
1466
+ throw new Error('卡片回复结果未确认:未返回 message_id')
1467
+ }
1468
+ return response
1469
+ })
1226
1470
  if (r && r.code === 0) return pick(r)
1227
- } catch { /* 原消息已撤回/超期 ⇒ 退回直接发 */ }
1471
+ } catch (e) {
1472
+ if (operationBoundary) throw e
1473
+ /* 原消息已撤回/超期 ⇒ 退回直接发 */
1474
+ }
1228
1475
  }
1229
- return pick(await client.im.message.create({
1230
- params: { receive_id_type: 'chat_id' },
1231
- data: { receive_id: chatId, msg_type: 'interactive', content },
1232
- }))
1476
+ const r = await cardOperation(operationBoundary, operationKey, 'message.create',
1477
+ { chatId, cardId }, async () => {
1478
+ const response = await client.im.message.create({
1479
+ params: { receive_id_type: 'chat_id' },
1480
+ data: { receive_id: chatId, msg_type: 'interactive', content },
1481
+ })
1482
+ if (operationBoundary) {
1483
+ assertCardOk(response, '投递卡')
1484
+ if (response?.code !== 0) throw new Error('卡片投递结果未确认:未返回 code=0')
1485
+ if (!pick(response)) throw new Error('卡片投递结果未确认:未返回 message_id')
1486
+ }
1487
+ return response
1488
+ })
1489
+ return pick(r)
1233
1490
  }
1234
1491
 
1235
1492
  async function sendChatCard(client, chatId, cardJson) {
@@ -1255,7 +1512,7 @@ async function sendChatCard(client, chatId, cardJson) {
1255
1512
  // sequence 缺省 = 秒级时间戳(单发一张卡时用它最省事);写入器那种「同一张卡要写很多次」的
1256
1513
  // 场合必须自带序号,由它那个唯一的分配器统一发 —— 两条路各自取号才是会撞的写法。
1257
1514
  async function updateCardEntity(client, cardId, cardJson, sequence = null) {
1258
- assertCardOk(await client.cardkit.v1.card.update({
1515
+ return assertCardOk(await client.cardkit.v1.card.update({
1259
1516
  path: { card_id: cardId },
1260
1517
  data: { card: { type: 'card_json', data: JSON.stringify(cardJson) }, sequence: sequence == null ? Math.floor(Date.now() / 1000) : sequence },
1261
1518
  }), '更新卡')
@@ -1287,7 +1544,7 @@ async function fetchChatHistory(client, chatId, n = 20) {
1287
1544
  createTimeMs: Number(m.create_time) || 0,
1288
1545
  msgType: m.msg_type,
1289
1546
  text: parseText({ message_type: m.msg_type, message_id: m.message_id, content: (m.body && m.body.content) || '' }), // message_id 进附件标记 ⇒ 历史里的图也能 download
1290
- // ⚠ @ 信息只在**这一层**补(v0.11.68):parseText 把 `@_user_N` 占位符抹掉是对的(实时收信那条路也用它,
1547
+ // ⚠ @ 信息只在**这一层**补:parseText 把 `@_user_N` 占位符抹掉是对的(实时收信那条路也用它,
1291
1548
  // agent 每轮收到的正文本就不该带占位符),但历史里因此看不出「这条是发给崔总的」还是「问我的」——
1292
1549
  // 排查者据此误判过「真人消息被静默吞掉」(实为 @了别人未@bot,门控正确不触发);agent 读 history 也分不清。
1293
1550
  // 某项目近 200 条里真人 76 条,12 条(16%)是这种。id 可能是字符串也可能是 {open_id} 对象,两种都认。